From c0444dc1106e44e1b536afe9041f8b4067253918 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:20:41 +0900 Subject: [PATCH 001/106] =?UTF-8?q?=F0=9F=94=92=20harden=20MAIN-world=20GM?= =?UTF-8?q?=20RPC=20capability=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.ts | 12 +- src/app/repo/scripts.ts | 7 + .../service/content/create_context.test.ts | 34 +++ src/app/service/content/create_context.ts | 18 +- src/app/service/content/exec_script.ts | 10 +- .../service/content/gm_api/cat_agent.test.ts | 10 + src/app/service/content/gm_api/cat_agent.ts | 138 ++++++----- .../service/content/gm_api/cat_agent_task.ts | 34 ++- src/app/service/content/gm_api/gm_api.test.ts | 106 ++++++++- src/app/service/content/gm_api/gm_api.ts | 69 ++++-- src/app/service/content/gm_api/gm_context.ts | 10 +- src/app/service/content/gm_api/gm_xhr.ts | 2 +- src/app/service/content/listener_manager.ts | 37 ++- src/app/service/content/page_rpc.test.ts | 138 +++++++++++ src/app/service/content/page_rpc.ts | 217 ++++++++++++++++++ .../service/content/script_executor.test.ts | 67 ++++++ src/app/service/content/script_executor.ts | 69 ++++-- src/app/service/content/scripting.ts | 47 +++- src/app/service/content/utils.ts | 9 +- .../service_worker/gm_api/gm_api.test.ts | 30 +++ .../service/service_worker/gm_api/gm_api.ts | 28 ++- src/app/service/service_worker/index.ts | 1 + .../service/service_worker/runtime.test.ts | 53 +++++ src/app/service/service_worker/runtime.ts | 87 ++++++- src/app/service/service_worker/types.ts | 17 ++ src/app/service/service_worker/utils.test.ts | 33 ++- 26 files changed, 1133 insertions(+), 150 deletions(-) create mode 100644 src/app/service/content/page_rpc.test.ts create mode 100644 src/app/service/content/page_rpc.ts diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..4a62f824f 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -293,7 +293,8 @@ export function forwardMessage( path: string, receiverFrom: Server, senderTo: MessageSend, - middleware?: ApiFunctionSync + middleware?: ApiFunctionSync, + transform?: (params: any, con: IGetSender) => any ) { const handler = async (params: any, fromCon: IGetSender): Promise => { const fromConnect: MessageConnect | undefined = fromCon.getConnect(); @@ -308,7 +309,7 @@ export function forwardMessage( return sendMessage(senderTo, prefix + "/" + path, params); } }; - receiverFrom.on(path, (params, sender) => { + const process = (params: any, sender: IGetSender) => { if (middleware) { // 此处是为了处理CustomEventMessage的同步消息情况 const resp = middleware(params, sender) as any; @@ -324,5 +325,12 @@ export function forwardMessage( } } return handler(params, sender); + }; + receiverFrom.on(path, (params, sender) => { + if (!transform) return process(params, sender); + const transformed = transform(params, sender); + return transformed instanceof Promise + ? transformed.then((data) => process(data, sender)) + : process(transformed, sender); }); } diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index cf99f6de4..2171c4710 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -3,6 +3,7 @@ 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"; +import type { ScriptEnvTag } from "@Packages/message/consts"; // 脚本模型 export type SCRIPT_TYPE = 1 | 2 | 3; @@ -110,6 +111,12 @@ export interface ScriptRunResource extends Script { resourceByType?: ScriptResourceByType; metadata: SCMetadata; // 经自定义覆盖的 Metadata originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude) + /** 页面执行环境绑定的能力句柄。 */ + executionHandle?: string; + /** 执行脚本所在的页面环境。 */ + executionEnvTag?: ScriptEnvTag; + /** 页面执行绑定使用的值更新关联标识。 */ + executionRunFlag?: string; } /** diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 058e59c95..a5eb5bc00 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { createContext, createProxyContext, shouldFnBind, type RealmRoots } from "./create_context"; +import { GMContextApiGet } from "./gm_api/gm_context"; import { trimScriptInfo } from "./utils"; type AnyRecord = Record; @@ -304,6 +305,39 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("uses the service-worker execution run flag for value acknowledgments", () => { + const script = { + ...createScriptInfo({ grant: ["GM_getValue"] }), + executionRunFlag: "canonical-run", + } as TScriptInfo; + const context = createContext( + script, + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + new Set(["GM_getValue"]) + ); + + expect((context as unknown as { runFlag: string }).runFlag).toBe("canonical-run"); + }); + + it("installs capabilities without looking up a page-patchable Function.prototype.bind", () => { + const apiValues = GMContextApiGet("GM_getValue")!; + const originalApi = apiValues[0].api; + const replacement = function (this: unknown, key: string, fallback?: unknown) { + return fallback; + }; + Object.defineProperty(replacement, "bind", { configurable: true, value: undefined }); + apiValues[0].api = replacement; + try { + const context = createTestContext(["GM_getValue"]); + expect(context.GM_getValue("key", "fallback")).toBe("fallback"); + } finally { + apiValues[0].api = originalApi; + } + }); + const resourceGrantChecks: Array<{ grant: string; read: (context: ReturnType) => unknown; diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 44d4ccf20..b9877fd6d 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -9,6 +9,14 @@ import { ListenerManager } from "./listener_manager"; import { createGMBase } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; +const nativeReflectApply = Reflect.apply; + +const createCapability = (api: (...args: any[]) => any, receiver: object) => { + const capability = (...args: any[]) => nativeReflectApply(api, receiver, args); + Object.defineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); + return capability; +}; + // 不要使用 {}, 改使用 Object.create(null) - 避免在页面生成沙盒时,受到 Object.prototype 被注入的影响 // 构建沙盒上下文 @@ -41,7 +49,7 @@ export const createContext = ( scriptRes, valueChangeListener, EE, - runFlag: uuidv4(), + runFlag: scriptRes.executionRunFlag || uuidv4(), eventId: 10000, GM: GM, GM_info: GMInfo, @@ -73,7 +81,7 @@ export const createContext = ( if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) grantSet.add(grant); for (const { fnKey, api, param } of s) { - grantedAPIs[fnKey] = api.bind(context); + grantedAPIs[fnKey] = createCapability(api, context); const depend = param?.depend; if (depend) { for (const grant of depend) { @@ -185,14 +193,14 @@ const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: Descrip if (typeof descriptor.value !== "function" || isConstructorOrInterface(descriptor.value)) return descriptor; return { ...descriptor, - value: bindFn.call(descriptor.value, receiver), + value: nativeReflectApply(bindFn, descriptor.value, [receiver]), }; } if (!descriptor.get && !descriptor.set) return descriptor; return { ...descriptor, - get: descriptor.get ? bindFn.call(descriptor.get, receiver) : undefined, - set: descriptor.set ? bindFn.call(descriptor.set, receiver) : undefined, + get: descriptor.get ? nativeReflectApply(bindFn, descriptor.get, [receiver]) : undefined, + set: descriptor.set ? nativeReflectApply(bindFn, descriptor.set, [receiver]) : undefined, }; }; diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..dc76d462b 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -92,7 +92,15 @@ export default class ExecScript { }; // 早期启动的脚本,处理GM API - updateEarlyScriptGMInfo(envInfo: GMInfoEnv) { + updateEarlyScriptGMInfo(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) { + if (scriptInfo?.executionHandle && scriptInfo.executionEnvTag) { + this.scriptRes.executionHandle = scriptInfo.executionHandle; + this.scriptRes.executionEnvTag = scriptInfo.executionEnvTag; + this.scriptRes.executionRunFlag = scriptInfo.executionRunFlag; + if (this.sandboxContext && scriptInfo.executionRunFlag) { + this.sandboxContext.runFlag = scriptInfo.executionRunFlag; + } + } let GM_info; if (this.sandboxContext) { // 触发loadScriptResolve diff --git a/src/app/service/content/gm_api/cat_agent.test.ts b/src/app/service/content/gm_api/cat_agent.test.ts index 1ea74a921..6bec37b49 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -65,6 +65,16 @@ function createInstance( } describe("ConversationInstance 命令机制", () => { + it("不会把通用 GM 传输能力作为实例属性暴露", () => { + const { instance } = createInstance(); + const ownNames = Object.getOwnPropertyNames(instance); + + expect(ownNames).not.toContain("gmSendMessage"); + expect(ownNames).not.toContain("gmConnect"); + expect(ownNames).not.toContain("conv"); + expect(ownNames).not.toContain("scriptUuid"); + }); + it("内置 /new 命令清空消息历史", async () => { const { instance, gmSendMessage } = createInstance(); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 8f63e06cd..fbdc67ee2 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -19,6 +19,8 @@ import type { } from "@App/app/service/agent/core/types"; import { getTextContent } from "@App/app/service/agent/core/content_utils"; +const nativeReflectApply = Reflect.apply; + export type ConversationStreamChunk = | StreamChunk | { @@ -81,13 +83,31 @@ function resolveToolCall( // 对话实例,暴露给用户脚本 // 导出供测试使用 +type ConversationPrivateState = { + conv: Conversation; + gmSendMessage: (api: string, params: any[]) => Promise; + gmConnect: (api: string, params: any[]) => Promise; + scriptUuid: string; + commandHandlers: Map; + cache?: boolean; + systemPrompt?: string; + background: boolean; +}; + +const conversationStates = new WeakMap(); +const weakMapGet = WeakMap.prototype.get; +const weakMapSet = WeakMap.prototype.set; + +const getConversationState = (instance: ConversationInstance): ConversationPrivateState => { + const state = nativeReflectApply(weakMapGet, conversationStates, [instance]); + if (!state) throw new Error("conversation instance is invalid"); + return state; +}; + export class ConversationInstance { public toolHandlers: Map = new Map(); public toolDefs: ToolDefinition[] = []; - private commandHandlers: Map = new Map(); public ephemeral: boolean; - private cache?: boolean; - private systemPrompt?: string; public messageHistory: Array<{ role: MessageRole; content: MessageContent; @@ -95,13 +115,11 @@ export class ConversationInstance { toolCalls?: ToolCall[]; }> = []; - private background: boolean; - constructor( - private conv: Conversation, - private gmSendMessage: (api: string, params: any[]) => Promise, - private gmConnect: (api: string, params: any[]) => Promise, - private scriptUuid: string, + conv: Conversation, + gmSendMessage: (api: string, params: any[]) => Promise, + gmConnect: (api: string, params: any[]) => Promise, + scriptUuid: string, initialTools?: ConversationCreateOptions["tools"], commands?: Record, ephemeral?: boolean, @@ -109,10 +127,18 @@ export class ConversationInstance { cache?: boolean, background?: boolean ) { + const state: ConversationPrivateState = { + conv, + gmSendMessage, + gmConnect, + scriptUuid, + commandHandlers: new Map(), + cache, + systemPrompt: system, + background: background || false, + }; + nativeReflectApply(weakMapSet, conversationStates, [this, state]); this.ephemeral = ephemeral || false; - this.background = background || false; - this.cache = cache; - this.systemPrompt = system; if (initialTools) { for (const tool of initialTools) { this.toolHandlers.set(tool.name, tool.handler); @@ -121,7 +147,7 @@ export class ConversationInstance { } // 注册内置 /new 命令 - this.commandHandlers.set("/new", async () => { + state.commandHandlers.set("/new", async () => { await this.clear(); return "对话已清空"; }); @@ -129,21 +155,21 @@ export class ConversationInstance { // 用户传入的 commands 覆盖内置命令 if (commands) { for (const [name, handler] of Object.entries(commands)) { - this.commandHandlers.set(name, handler); + state.commandHandlers.set(name, handler); } } } get id() { - return this.conv.id; + return getConversationState(this).conv.id; } get title() { - return this.conv.title; + return getConversationState(this).conv.title; } get modelId() { - return this.conv.modelId; + return getConversationState(this).conv.modelId; } // 发送消息并获取回复(内置 tool calling 循环) @@ -154,6 +180,7 @@ export class ConversationInstance { if (cmdResult !== undefined) return cmdResult; const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = getConversationState(this); // ephemeral 模式:追加 user message 到内存历史 if (this.ephemeral) { @@ -162,27 +189,27 @@ export class ConversationInstance { // 通过 GM API connect 建立流式连接 const connectParams: Record = { - conversationId: this.conv.id, - generation: this.conv.generation, + conversationId: state.conv.id, + generation: state.conv.generation, message: content, tools: toolDefs.length > 0 ? toolDefs : undefined, - scriptUuid: this.scriptUuid, + scriptUuid: state.scriptUuid, }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; + if (state.cache !== undefined) { + connectParams.cache = state.cache; } - if (this.background) { + if (state.background) { connectParams.background = true; } if (this.ephemeral) { connectParams.ephemeral = true; connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + connectParams.system = state.systemPrompt; + connectParams.modelId = state.conv.modelId; } - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + const conn = await state.gmConnect("CAT_agentConversationChat", [connectParams]); const reply = await this.processChat(conn, handlers); @@ -221,6 +248,7 @@ export class ConversationInstance { } const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = getConversationState(this); // ephemeral 模式:追加 user message 到内存历史 if (this.ephemeral) { @@ -228,27 +256,27 @@ export class ConversationInstance { } const connectParams: Record = { - conversationId: this.conv.id, - generation: this.conv.generation, + conversationId: state.conv.id, + generation: state.conv.generation, message: content, tools: toolDefs.length > 0 ? toolDefs : undefined, - scriptUuid: this.scriptUuid, + scriptUuid: state.scriptUuid, }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; + if (state.cache !== undefined) { + connectParams.cache = state.cache; } - if (this.background) { + if (state.background) { connectParams.background = true; } if (this.ephemeral) { connectParams.ephemeral = true; connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + connectParams.system = state.systemPrompt; + connectParams.modelId = state.conv.modelId; } - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + const conn = await state.gmConnect("CAT_agentConversationChat", [connectParams]); // chat 连接不会收到 sync 事件(sync 快照仅由 attach 的 SW 端发出), // 公开签名与 scriptcat.d.ts 保持一致:chatStream 只产出 StreamChunk @@ -274,7 +302,7 @@ export class ConversationInstance { const parsed = this.parseCommand(content); if (!parsed) return undefined; - const handler = this.commandHandlers.get(parsed.name); + const handler = getConversationState(this).commandHandlers.get(parsed.name); if (!handler) return undefined; const result = await handler(parsed.args, this); @@ -302,11 +330,12 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { + const state = getConversationState(this); if (this.ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 return this.messageHistory.map((msg, idx) => ({ id: `ephemeral-${idx}`, - conversationId: this.conv.id, + conversationId: state.conv.id, role: msg.role, content: msg.content, toolCallId: msg.toolCallId, @@ -314,12 +343,12 @@ export class ConversationInstance { createtime: Date.now(), })); } - const messages = await this.gmSendMessage("CAT_agentConversation", [ + const messages = await state.gmSendMessage("CAT_agentConversation", [ { action: "getMessages", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); return messages || []; @@ -331,32 +360,35 @@ export class ConversationInstance { this.messageHistory = []; return; } - await this.gmSendMessage("CAT_agentConversation", [ + const state = getConversationState(this); + await state.gmSendMessage("CAT_agentConversation", [ { action: "clearMessages", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); } // 持久化对话 async save(): Promise { - await this.gmSendMessage("CAT_agentConversation", [ + const state = getConversationState(this); + await state.gmSendMessage("CAT_agentConversation", [ { action: "save", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); } // 附加到后台运行中的会话,返回流式事件(首个 chunk 为 sync 快照) async attach(): Promise> { - const conn = await this.gmConnect("CAT_agentAttachToConversation", [ - { conversationId: this.conv.id, generation: this.conv.generation, scriptUuid: this.scriptUuid }, + const state = getConversationState(this); + const conn = await state.gmConnect("CAT_agentAttachToConversation", [ + { conversationId: state.conv.id, generation: state.conv.generation, scriptUuid: state.scriptUuid }, ]); return this.processStream(conn, new Map()); } @@ -895,8 +927,8 @@ function buildInstance( ): ConversationInstance { return new ConversationInstance( conv, - ctx.sendMessage.bind(ctx), - ctx.connect.bind(ctx), + (api, params) => nativeReflectApply(ctx.sendMessage, ctx, [api, params]), + (api, params) => nativeReflectApply(ctx.connect, ctx, [api, params]), ctx.scriptRes?.uuid || "", options?.tools, options?.commands, diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index 70321ee91..83fe76a40 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -17,8 +17,20 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; -// listener id → { eventName, callback } 映射,供 removeListener 使用 -const listenerMap = new Map void }>(); +type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; +const listenerMaps = new WeakMap(); +const nativeReflectApply = Reflect.apply; +const weakMapGet = WeakMap.prototype.get; +const weakMapSet = WeakMap.prototype.set; + +const getListenerRecords = (owner: object): ListenerRecord[] => { + let records = nativeReflectApply(weakMapGet, listenerMaps, [owner]); + if (!records) { + records = []; + nativeReflectApply(weakMapSet, listenerMaps, [owner, records]); + } + return records; +}; // CAT.agent.task API,注入到脚本上下文 export default class CATAgentTaskApi { @@ -112,7 +124,8 @@ export default class CATAgentTaskApi { }; ctx.EE.on(eventName, wrappedCallback); - listenerMap.set(listenerId, { eventName, callback: wrappedCallback }); + const records = getListenerRecords(ctx); + records[records.length] = { id: listenerId, eventName, callback: wrappedCallback }; return listenerId; } @@ -122,10 +135,19 @@ export default class CATAgentTaskApi { const ctx = this as unknown as GMBaseContext; if (!ctx.EE) return; - const entry = listenerMap.get(listenerId); - if (entry) { + const records = getListenerRecords(ctx); + let index = -1; + for (let i = 0; i < records.length; i += 1) { + if (records[i]?.id === listenerId) { + index = i; + break; + } + } + if (index >= 0) { + const entry = records[index]; + for (let i = index + 1; i < records.length; i += 1) records[i - 1] = records[i]; + records.length -= 1; ctx.EE.off(entry.eventName, entry.callback); - listenerMap.delete(listenerId); } } } 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 cf926aed6..48f9cbce2 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -3,7 +3,7 @@ import ExecScript from "../exec_script"; import type { ScriptLoadInfo } from "@App/app/service/service_worker/types"; import type { GMInfoEnv, ScriptFunc } from "../types"; import { compileScript, compileScriptCode } from "../utils"; -import type { Message } from "@Packages/message/types"; +import type { Message, MessageConnect } 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"; @@ -32,6 +32,86 @@ const envInfo: GMInfoEnv = { isIncognito: false, }; +describe("early-start page RPC", () => { + it("waits for the page binding before opening a long-lived connection", async () => { + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + const connection = {} as MessageConnect; + const connectMessage = vi.fn().mockResolvedValue(connection); + const script = { + ...scriptRes, + uuid: "early-start-script", + executionHandle: "page-binding", + executionEnvTag: "it", + } as ScriptLoadInfo; + const api = new GMApi("scripting", { connect: connectMessage } as unknown as Message, {} as Message, script); + Object.defineProperty(api, "loadScriptPromise", { configurable: true, value: ready, writable: true }); + + const pending = api.connect("GM_xmlhttpRequest", []); + expect(connectMessage).not.toHaveBeenCalled(); + + release(); + await expect(pending).resolves.toBe(connection); + expect(connectMessage).toHaveBeenCalledWith({ + action: "scripting/runtime/gmApi", + data: expect.objectContaining({ + api: "GM_xmlhttpRequest", + handle: "page-binding", + envTag: "it", + }), + }); + }); + + it("uses the authoritative run flag for early-start async value acknowledgments", async () => { + const script = { + ...scriptRes, + uuid: "early-start-value-script", + metadata: { grant: ["GM.setValue"], "early-start": [""], "run-at": ["document-start"] }, + executionHandle: undefined, + executionEnvTag: undefined, + executionRunFlag: undefined, + } as ScriptLoadInfo; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: { sendMessage: mockSendMessage } as unknown as Message, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + + exec.scriptFunc = function (this: any) { + return this.GM.setValue("a", 123); + } as unknown as ScriptFunc; + const result = exec.exec(); + await Promise.resolve(); + expect(mockSendMessage).not.toHaveBeenCalled(); + + exec.updateEarlyScriptGMInfo(envInfo, { + ...script, + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "canonical-run", + }); + await Promise.resolve(); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + + const request = mockSendMessage.mock.calls[0][0].data; + exec.valueUpdate({ + id: request.params[0], + entries: [["a", encodeRValue(123), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: "canonical-run", tabId: -2 }, + valueUpdated: true, + }); + + await expect(result).resolves.toBeUndefined(); + }); +}); + const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({ url, content, @@ -1137,6 +1217,9 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue", "GM_addValueChangeListener"]; script.metadata.storageName = ["testStorage"]; + script.executionHandle = "page-binding"; + script.executionEnvTag = "it"; + script.executionRunFlag = "canonical-run"; script.code = ` return new Promise(resolve=>{ GM_addValueChangeListener("param1", (name, oldValue, newValue, remote)=>{ @@ -1211,6 +1294,27 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const ret2 = await retPromise; expect(ret2).toEqual({ name: "param2", oldValue: undefined, newValue: 456, remote: true }); }); + + it.concurrent("value change listeners receive snapshots instead of the cached object", () => { + const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_getValue", "GM_addValueChangeListener"]; + script.value = {}; + const api = new GMApi("test", {} as Message, {} as Message, script); + api.GM_addValueChangeListener("snapshot", (_name, _oldValue, newValue) => { + const snapshot = newValue as { nested: { value: number } }; + snapshot.nested.value = 99; + }); + + api.valueUpdate({ + entries: [["snapshot", encodeRValue({ nested: { value: 1 } }), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: "remote", tabId: -2 }, + valueUpdated: true, + }); + + expect(api.GM_getValue("snapshot")).toEqual({ nested: { value: 1 } }); + }); it.concurrent("异步GM.setValue,等待回调", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM.getValue", "GM.setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index d9c52c8e4..450f23ced 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -12,6 +12,7 @@ import type { MessageRequest, } from "@App/app/service/service_worker/types"; import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; +import { uuidv4 } from "@App/pkg/utils/uuid"; import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; @@ -59,7 +60,21 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; -const valueChangePromiseMap = new Map(); +const valueChangePromiseMap: Record void> = Object.create(null); + +const notificationTagMaps = new WeakMap>(); +const nativeReflectApply = Reflect.apply; +const weakMapGet = WeakMap.prototype.get; +const weakMapSet = WeakMap.prototype.set; + +const getNotificationTagMap = (owner: object): Map => { + let map = nativeReflectApply(weakMapGet, notificationTagMaps, [owner]); + if (!map) { + map = new Map(); + nativeReflectApply(weakMapSet, notificationTagMaps, [owner, map]); + } + return map; +}; const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { @@ -138,12 +153,21 @@ class GM_Base implements IGM_Base { } let ret; try { - ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, { + const request = { uuid: this.scriptRes.uuid, api, params, runFlag: this.runFlag, - } as MessageRequest); + ...(this.scriptRes.executionHandle && this.scriptRes.executionEnvTag + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + envTag: this.scriptRes.executionEnvTag, + } + : {}), + } as MessageRequest; + ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, request); } catch (e: any) { if (`${e?.message || e}`.includes("Extension context invalidated.")) { this.setInvalidContext(); // 之后不再进行 sendMessage 跟 EE操作 @@ -157,14 +181,27 @@ class GM_Base implements IGM_Base { // 长连接使用,connect只用于接受消息,不发送消息 @GMContext.protected() - public connect(api: string, params: any[]) { + public async connect(api: string, params: any[]) { if (!this.message || !this.scriptRes) return new Promise(() => {}); - return connect(this.message, `${this.prefix}/runtime/gmApi`, { + if (this.loadScriptPromise) { + await this.loadScriptPromise; + } + if (!this.message || !this.scriptRes) return new Promise(() => {}); + const request = { uuid: this.scriptRes.uuid, api, params, runFlag: this.runFlag, - } as MessageRequest); + ...(this.scriptRes.executionHandle && this.scriptRes.executionEnvTag + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + envTag: this.scriptRes.executionEnvTag, + } + : {}), + } as MessageRequest; + return connect(this.message, `${this.prefix}/runtime/gmApi`, request); } @GMContext.protected() @@ -176,9 +213,9 @@ class GM_Base implements IGM_Base { const valueStore = scriptRes.value; const remote = sender.runFlag !== this.runFlag; if (!remote && id) { - const fn = valueChangePromiseMap.get(id); + const fn = valueChangePromiseMap[id]; if (fn) { - valueChangePromiseMap.delete(id); + delete valueChangePromiseMap[id]; fn(); } } @@ -195,7 +232,9 @@ class GM_Base implements IGM_Base { } else { valueStore[key] = value; } - this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); + const listenerValue = value && typeof value === "object" ? customClone(value) : value; + const listenerOldValue = oldValue && typeof oldValue === "object" ? customClone(oldValue) : oldValue; + this.valueChangeListener.execute(key, listenerOldValue, listenerValue, remote, sender.tabId); } } } @@ -210,11 +249,6 @@ class GM_Base implements IGM_Base { // GMApi 定义 外部用API函数。不使用@protected export default class GMApi extends GM_Base { - /** - * - */ - notificationTagMap?: Map; - constructor( public prefix: string, public message: Message, @@ -232,7 +266,6 @@ export default class GMApi extends GM_Base { scriptRes, valueChangeListener, EE, - notificationTagMap: new Map(), eventId: 0, setInvalidContext() { if (invalid) return; @@ -290,7 +323,7 @@ export default class GMApi extends GM_Base { } const id = `${valChangeRandomId}::${++valChangeCounterId}`; if (promise) { - valueChangePromiseMap.set(id, promise); + valueChangePromiseMap[id] = promise; } if (value === undefined) { delete a.scriptRes.value[key]; @@ -320,7 +353,7 @@ export default class GMApi extends GM_Base { } const id = `${valChangeRandomId}::${++valChangeCounterId}`; if (promise) { - valueChangePromiseMap.set(id, promise); + valueChangePromiseMap[id] = promise; } const valueStore = a.scriptRes.value; const keyValuePairs = [] as [string, REncoded][]; @@ -1243,7 +1276,7 @@ export default class GMApi extends GM_Base { onclick?: GMTypes.NotificationOnClick ): Promise { if (gmApi.isInvalidContext()) return Promise.resolve(); - const notificationTagMap: Map = gmApi.notificationTagMap || (gmApi.notificationTagMap = new Map()); + const notificationTagMap = getNotificationTagMap(gmApi); gmApi.eventId += 1; let data: GMTypes.NotificationDetails; if (typeof detail === "string") { diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 25bfaa109..c604b7019 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,17 +1,17 @@ import type { ApiParam, ApiValue } from "../types"; -const apis: Map = new Map(); +const apis: Record = Object.create(null); export function GMContextApiGet(name: string): ApiValue[] | undefined { // 回传 Api 列表 - return apis.get(name); + return apis[name]; } function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 - let m: ApiValue[] | undefined = apis.get(grant); - if (!m) apis.set(grant, (m = [])); - m.push({ fnKey, api, param }); + let m: ApiValue[] | undefined = apis[grant]; + if (!m) apis[grant] = m = []; + m[m.length] = { fnKey, api, param }; } export const protect: { [key: string]: any } = {}; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index d3b147f88..598406983 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -509,13 +509,13 @@ export function GM_xmlhttpRequest( finalResultBuffers = null; finalResultText = null; const xhrResponse = makeXHRCallbackParam?.(data) ?? {}; - details.onloadend?.(xhrResponse); if (errorOccur === null) { retPromiseResolve?.(xhrResponse); } else { retPromiseReject?.(errorOccur); } refCleanup?.(); + details.onloadend?.(xhrResponse); } }; doAbort = (data: TXhrCallBackArg) => { diff --git a/src/app/service/content/listener_manager.ts b/src/app/service/content/listener_manager.ts index cc4de0f9e..39f180dda 100644 --- a/src/app/service/content/listener_manager.ts +++ b/src/app/service/content/listener_manager.ts @@ -2,46 +2,43 @@ // 删除会较慢但执行会较快 export class ListenerManager void> { private counterId = 0; - private readonly listeners = new Map>(); + private readonly listeners: Array<{ key: string; id: number; handler: T }> = []; public add(key: string, handler: T): number { const id = ++this.counterId; - let listenrMap = this.listeners.get(key); - if (!listenrMap) { - this.listeners.set(key, (listenrMap = new Map())); - } - listenrMap.set(id, handler); + this.listeners[this.listeners.length] = { key, id, handler }; return id; } public execute(key: string, ...args: T extends (key: string, ...a: infer A) => any ? A : never): void { - const handlers = this.listeners.get(key); - if (handlers) { - for (const handler of handlers.values()) { - handler?.(key, ...args); + for (let i = 0; i < this.listeners.length; ) { + const listener = this.listeners[i]; + if (listener?.key !== key) { + i += 1; + continue; } + const listenerId = listener.id; + listener.handler?.(key, ...args); + if (this.listeners[i]?.id === listenerId) i += 1; } } public remove(id: number | string): boolean { const idNum = +id || 0; if (idNum > 0) { - for (const [key, handlers] of this.listeners) { - if (handlers.delete(idNum)) { - if (handlers.size === 0) { - this.listeners.delete(key); - } - return true; + for (let i = 0; i < this.listeners.length; i += 1) { + if (this.listeners[i]?.id !== idNum) continue; + for (let j = i + 1; j < this.listeners.length; j += 1) { + this.listeners[j - 1] = this.listeners[j]; } + this.listeners.length -= 1; + return true; } } return false; } public clear(): void { - for (const [_key, handlers] of this.listeners) { - handlers.clear(); - } - this.listeners.clear(); + this.listeners.length = 0; } } diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts new file mode 100644 index 000000000..39cb74802 --- /dev/null +++ b/src/app/service/content/page_rpc.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { getPageRpcAllowedAPIs, PageRpcError, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; + +describe("page GM RPC", () => { + it("expands only the helper operations reachable from an explicit public grant", () => { + const allowed = getPageRpcAllowedAPIs(["CAT.agent.opfs", "GM_xmlhttpRequest"]); + + expect(allowed).toEqual( + expect.arrayContaining([ + "CAT.agent.opfs", + "CAT_agentOPFS", + "CAT_fetchBlob", + "GM_xmlhttpRequest", + "GM.xmlhttpRequest", + "CAT_fetchDocument", + ]) + ); + expect(allowed).not.toContain("CAT_agentSkills"); + }); + + it("includes APIs exposed through the same dependency graph as the script context", () => { + const allowed = getPageRpcAllowedAPIs(["GM.openInTab"]); + + expect(allowed).toEqual(expect.arrayContaining(["GM.openInTab", "GM_openInTab", "GM_closeInTab"])); + }); + + it("accepts a request for the active execution binding and clones parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"], undefined, "canonical-run"); + const params = { nested: { value: 1 } }; + + const request = validatePageGMRequest( + { + version: 1, + requestId: "request-a", + handle, + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [params], + }, + registry + ); + + expect(request).toEqual({ + version: 1, + requestId: "request-a", + handle, + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [params], + runFlag: "canonical-run", + }); + expect(request.params[0]).not.toBe(params); + }); + + it("rejects an unknown, stale, or mismatched execution binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "a", + handle: "missing", + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [], + }, + registry + ) + ).toThrow(PageRpcError); + + registry.revoke(handle); + expect(() => + validatePageGMRequest( + { version: 1, requestId: "b", handle, uuid: "script-a", envTag: "it", api: "GM_getValue", params: [] }, + registry + ) + ).toThrow(PageRpcError); + + const activeHandle = registry.register("script-a", "it", ["GM_getValue"]); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "c", + handle: activeHandle, + uuid: "script-b", + envTag: "it", + api: "GM_getValue", + params: [], + }, + registry + ) + ).toThrow(PageRpcError); + }); + + it("rejects APIs outside the binding and packets with accessors or unsupported values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const accessorRequest = { + version: 1, + requestId: "a", + handle, + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [], + }; + Object.defineProperty(accessorRequest, "api", { get: () => "GM_getValue" }); + + expect(() => validatePageGMRequest(accessorRequest, registry)).toThrow(PageRpcError); + expect(() => + validatePageGMRequest( + { version: 1, requestId: "b", handle, uuid: "script-a", envTag: "it", api: "GM_setValue", params: [] }, + registry + ) + ).toThrow(PageRpcError); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "c", + handle, + uuid: "script-a", + envTag: "it", + api: "GM_getValue", + params: [() => undefined], + }, + registry + ) + ).toThrow(PageRpcError); + }); +}); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts new file mode 100644 index 000000000..2c6e18e9b --- /dev/null +++ b/src/app/service/content/page_rpc.ts @@ -0,0 +1,217 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { ScriptEnvTag } from "@Packages/message/consts"; +import { getGrantCandidates } from "./gm_api/grant"; + +export const PAGE_RPC_VERSION = 1 as const; +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; + +export type PageExecutionBinding = { + readonly handle: string; + readonly uuid: string; + readonly envTag: ScriptEnvTag; + readonly allowedAPIs: ReadonlySet; + readonly runFlag: string; + active: boolean; +}; + +export type PageGMRequest = { + readonly version: typeof PAGE_RPC_VERSION; + readonly requestId: string; + readonly handle: string; + readonly uuid: string; + readonly envTag: ScriptEnvTag; + readonly api: string; + readonly params: readonly unknown[]; + readonly runFlag: string; +}; + +const INTERNAL_APIS_BY_GRANT: Readonly> = { + "CAT.agent.conversation": ["CAT_agentConversation", "CAT_agentConversationChat", "CAT_agentAttachToConversation"], + "CAT.agent.dom": ["CAT_agentDom"], + "CAT.agent.model": ["CAT_agentModel"], + "CAT.agent.opfs": ["CAT_agentOPFS", "CAT_fetchBlob"], + "CAT.agent.skills": ["CAT_agentSkills"], + "CAT.agent.task": ["CAT_agentTask"], + CAT_fileStorage: ["CAT_fetchBlob", "CAT_createBlobUrl"], + GM_xmlhttpRequest: ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], + "GM.xmlhttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], + "GM.xmlHttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], +}; + +// ScriptingRuntime does not load the GM implementation module, so mirror its small dependency graph here. +const API_DEPENDENCIES: Readonly> = { + "GM.getValues": ["GM_getValues"], + "GM.addValueChangeListener": ["GM_addValueChangeListener"], + "GM.removeValueChangeListener": ["GM_removeValueChangeListener"], + "GM.log": ["GM_log"], + "GM.registerMenuCommand": ["GM_registerMenuCommand"], + CAT_registerMenuInput: ["GM_registerMenuCommand"], + "GM.addStyle": ["GM_addStyle"], + "GM.addElement": ["GM_addElement"], + "GM.unregisterMenuCommand": ["GM_unregisterMenuCommand"], + CAT_unregisterMenuInput: ["GM_unregisterMenuCommand"], + CAT_fileStorage: ["CAT_fetchBlob"], + "GM.openInTab": ["GM_openInTab", "GM_closeInTab"], + "GM.getTab": ["GM_getTab"], + "GM.saveTab": ["GM_saveTab"], + "GM.getTabs": ["GM_getTabs"], + "GM.setClipboard": ["GM_setClipboard"], + "GM.getResourceText": ["GM_getResourceText"], + "GM.getResourceURL": ["GM_getResourceURL"], + "GM.getResourceUrl": ["GM_getResourceURL"], +}; + +export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { + const allowed = new Set(); + const visited = new Set(); + const visitGrant = (grant: string): void => { + for (const candidate of getGrantCandidates(grant)) { + if (visited.has(candidate)) continue; + visited.add(candidate); + allowed.add(candidate); + for (const api of INTERNAL_APIS_BY_GRANT[candidate] || []) allowed.add(api); + for (const dependency of API_DEPENDENCIES[candidate] || []) visitGrant(dependency); + } + }; + for (const grant of grants) visitGrant(grant); + return [...allowed]; +}; + +export class PageRpcError extends Error { + constructor(message: string) { + super(message); + this.name = "PageRpcError"; + } +} + +const ownData = (value: object, key: PropertyKey): unknown => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new PageRpcError(`page RPC field ${String(key)} must be a data property`); + } + return descriptor.value; +}; + +const assertDataOnly = (value: unknown, seen: Set): void => { + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + + let keys: (string | symbol)[]; + try { + keys = Reflect.ownKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (const key of keys) { + if (typeof key === "symbol") throw new PageRpcError("page RPC values cannot contain symbol properties"); + const child = ownData(value, key); + assertDataOnly(child, seen); + } +}; + +const cloneParams = (params: unknown): readonly unknown[] => { + if (!Array.isArray(params)) throw new PageRpcError("page RPC params must be an array"); + assertDataOnly(params, new Set()); + if (!nativeStructuredClone) throw new PageRpcError("structured clone is unavailable"); + try { + return nativeStructuredClone(params) as readonly unknown[]; + } catch { + throw new PageRpcError("page RPC params are not cloneable"); + } +}; + +export class PageRpcRegistry { + private readonly bindings = new Map(); + + register( + uuid: string, + envTag: ScriptEnvTag, + allowedAPIs: readonly string[], + handle = uuidv4(), + runFlag = uuidv4() + ): string { + if (!uuid || !handle || this.bindings.has(handle)) { + throw new PageRpcError("invalid page execution binding"); + } + this.bindings.set(handle, { + handle, + uuid, + envTag, + allowedAPIs: new Set(allowedAPIs), + runFlag, + active: true, + }); + return handle; + } + + revoke(handle: string): void { + const binding = this.bindings.get(handle); + if (binding) binding.active = false; + } + + revokeAll(): void { + for (const binding of this.bindings.values()) binding.active = false; + } + + resolve(handle: string, uuid: string, envTag: ScriptEnvTag, api: string): PageExecutionBinding { + const binding = this.bindings.get(handle); + if (!binding?.active) throw new PageRpcError("page execution binding is inactive"); + if (binding.uuid !== uuid || binding.envTag !== envTag) { + throw new PageRpcError("page execution binding does not match the request"); + } + if (!binding.allowedAPIs.has(api)) throw new PageRpcError("API is not granted to this execution"); + return binding; + } +} + +const REQUEST_KEYS = ["version", "requestId", "handle", "uuid", "envTag", "api", "params", "runFlag"] as const; + +export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry): PageGMRequest => { + if (value === null || typeof value !== "object") throw new PageRpcError("page RPC request must be an object"); + + let keys: (string | symbol)[]; + try { + keys = Reflect.ownKeys(value); + } catch { + throw new PageRpcError("page RPC request cannot be inspected"); + } + const hasRunFlag = keys.includes("runFlag"); + if ( + keys.length !== REQUEST_KEYS.length - (hasRunFlag ? 0 : 1) || + keys.some((key) => typeof key !== "string" || !REQUEST_KEYS.includes(key as never)) + ) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + + const version = ownData(value, "version"); + const requestId = ownData(value, "requestId"); + const handle = ownData(value, "handle"); + const uuid = ownData(value, "uuid"); + const envTag = ownData(value, "envTag"); + const api = ownData(value, "api"); + const params = ownData(value, "params"); + const suppliedRunFlag = keys.includes("runFlag") ? ownData(value, "runFlag") : undefined; + + if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); + if (typeof requestId !== "string" || !requestId) throw new PageRpcError("page RPC requestId is invalid"); + if (typeof handle !== "string" || typeof uuid !== "string" || typeof envTag !== "string" || typeof api !== "string") { + throw new PageRpcError("page RPC identity fields are invalid"); + } + if (envTag !== "it" && envTag !== "ct") throw new PageRpcError("page RPC environment is invalid"); + if (suppliedRunFlag !== undefined && typeof suppliedRunFlag !== "string") { + throw new PageRpcError("page RPC runFlag is invalid"); + } + + const binding = registry.resolve(handle, uuid, envTag, api); + return { + version: PAGE_RPC_VERSION, + requestId, + handle, + uuid, + envTag, + api, + params: cloneParams(params), + runFlag: binding.runFlag, + }; +}; diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a4fe6c02a..e8ea976e1 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -2,6 +2,7 @@ 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 type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; const styleUrl = "https://example.com/style.css"; @@ -31,6 +32,72 @@ function makeScript(overrides: Partial { + it("does not resolve page-patchable Map methods for execution bookkeeping", () => { + const originalSet = Map.prototype.set; + const originalGet = Map.prototype.get; + const originalValues = Map.prototype.values; + const receivers: Map[] = []; + Map.prototype.set = function (key, value) { + receivers.push(this); + return originalSet.call(this, key, value); + }; + Map.prototype.get = function (key) { + receivers.push(this); + return originalGet.call(this, key); + }; + Map.prototype.values = function () { + receivers.push(this); + return originalValues.call(this); + }; + try { + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: makeScript(), + scriptFlag: "executor-test-flag", + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + expect(receivers).toHaveLength(0); + } finally { + Map.prototype.set = originalSet; + Map.prototype.get = originalGet; + Map.prototype.values = originalValues; + } + }); + + it("attaches the page execution binding when an early-start script is reconciled", () => { + const initial = makeScript({ metadata: { "early-start": [""], "run-at": ["document-start"] } }); + const executor = new ScriptExecutor({} as Message, {} as Message); + + executor.execScriptEntry({ + scriptLoadInfo: initial, + scriptFlag: initial.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + const exec = ( + executor as unknown as { + execScripts: Array<{ + exec: { + scriptRes: TScriptInfo; + updateEarlyScriptGMInfo: (envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) => void; + }; + }>; + } + ).execScripts[0].exec; + expect(exec.scriptRes.executionHandle).toBeUndefined(); + + exec.updateEarlyScriptGMInfo(initEnvInfo, { + ...initial, + executionHandle: "page-binding", + executionEnvTag: "it", + }); + + expect(exec.scriptRes.executionHandle).toBe("page-binding"); + expect(exec.scriptRes.executionEnvTag).toBe("it"); + }); + describe("resource execution", () => { let adoptedSheets: CSSStyleSheet[]; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 7bbcd9bb3..02df2320e 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -30,8 +30,8 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - earlyScriptFlag: Set = new Set(); - execScriptMap: Map = new Map(); + private readonly earlyScriptFlags: string[] = []; + private readonly execScripts: Array<{ uuid: string; exec: ExecScript }> = []; constructor( private msg: Message, @@ -40,18 +40,22 @@ export class ScriptExecutor { emitEvent(data: EmitEventRequest) { // 转发给脚本 - const exec = this.execScriptMap.get(data.uuid); - if (exec) { - exec.emitEvent(data.event, data.eventId, data.data); + for (let i = 0; i < this.execScripts.length; i += 1) { + const entry = this.execScripts[i]; + if (entry?.uuid === data.uuid) { + entry.exec.emitEvent(data.event, data.eventId, data.data); + return; + } } } valueUpdate(data: ValueUpdateDataEncoded) { // runtime/valueUpdate const { uuid, storageName } = data; - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { - val.valueUpdate(data); + for (let i = 0; i < this.execScripts.length; i += 1) { + const exec = this.execScripts[i]?.exec; + if (exec && (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName)) { + exec.valueUpdate(data); } } } @@ -69,11 +73,19 @@ export class ScriptExecutor { scripts.forEach((script) => { const flag = script.flag; // 如果是EarlyScriptFlag,处理沙盒环境 - if (this.earlyScriptFlag.has(flag)) { - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.flag === flag) { + let isEarlyScript = false; + for (let i = 0; i < this.earlyScriptFlags.length; i += 1) { + if (this.earlyScriptFlags[i] === flag) { + isEarlyScript = true; + break; + } + } + if (isEarlyScript) { + for (let i = 0; i < this.execScripts.length; i += 1) { + const exec = this.execScripts[i]?.exec; + if (exec?.scriptRes.flag === flag) { // 处理早期脚本的沙盒环境 - val.updateEarlyScriptGMInfo(envInfo); + exec.updateEarlyScriptGMInfo(envInfo, script); return; } } @@ -96,10 +108,16 @@ export class ScriptExecutor { scriptInfo: ScriptLoadInfo; }; const scriptFlag = detail?.scriptFlag; - if (typeof scriptFlag === "string") { + const scriptInfo = detail?.scriptInfo; + if ( + typeof scriptFlag === "string" && + scriptInfo && + typeof scriptInfo === "object" && + scriptInfo.flag === scriptFlag + ) { ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 // 检查是否有 urlPattern,有则执行匹配再决定是否略过注入 - if (detail.scriptInfo.scriptUrlPatterns) { + if (scriptInfo.scriptUrlPatterns) { // 以 REGEX 情况为例 // "@include /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围扩大,会比实际需要的广阔,然后在 earlyScript 把不符合 REGEX 的除去 // (All @include = false -> 除去) @@ -109,7 +127,7 @@ export class ScriptExecutor { // (Any @exclude = true -> 除去) // 注:如果一早已被除排,根本不会被 MV3 UserScripts API 注入。所以只考虑排除「多余的匹配」。(略过注入) try { - if (isUrlExcluded(window.location.href, detail.scriptInfo.scriptUrlPatterns)) { + if (isUrlExcluded(window.location.href, scriptInfo.scriptUrlPatterns)) { // 「多余的匹配」-> 略过注入 return; } @@ -117,7 +135,14 @@ export class ScriptExecutor { console.warn("Unexpected match error", e); } } - this.execEarlyScript(scriptFlag, detail.scriptInfo, envInfo); + let alreadyExecuted = false; + for (let i = 0; i < this.earlyScriptFlags.length; i += 1) { + if (this.earlyScriptFlags[i] === scriptFlag) { + alreadyExecuted = true; + break; + } + } + if (!alreadyExecuted) this.execEarlyScript(scriptFlag, scriptInfo, envInfo); } }; pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); @@ -135,7 +160,7 @@ export class ScriptExecutor { scriptFlag: flag, envInfo: envInfo, }); - this.earlyScriptFlag.add(flag); + this.earlyScriptFlags[this.earlyScriptFlags.length] = flag; } execScriptEntry(scriptEntry: ExecScriptEntry) { @@ -150,7 +175,15 @@ export class ScriptExecutor { code: scriptFunc, envInfo, }); - this.execScriptMap.set(scriptLoadInfo.uuid, execScript); + let replaced = false; + for (let i = 0; i < this.execScripts.length; i += 1) { + if (this.execScripts[i]?.uuid === scriptLoadInfo.uuid) { + this.execScripts[i] = { uuid: scriptLoadInfo.uuid, exec: execScript }; + replaced = true; + break; + } + } + if (!replaced) this.execScripts[this.execScripts.length] = { uuid: scriptLoadInfo.uuid, exec: execScript }; const metadata = scriptLoadInfo.metadata || {}; const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 7286d82b0..46a3f151f 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -7,6 +7,8 @@ import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; import type { ValueUpdateDataEncoded } from "./types"; +import { getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +import { uuidv4 } from "@App/pkg/utils/uuid"; const PageOrContent = { PAGE: 1, @@ -23,7 +25,8 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 // scripting页的处理 export default class ScriptingRuntime { - private activeStorageNames: Map | null = null; + private activeStorageNames = new Map(); + private readonly pageRpc = new PageRpcRegistry(); constructor( // 监听来自service_worker的消息 private readonly extServer: Server, @@ -74,10 +77,7 @@ export default class ScriptingRuntime { const record = changes["valueUpdateDelivery"]; if (record?.newValue) { const sendData = (record.newValue as { sendData: ValueUpdateDataEncoded }).sendData; - const activeOn = - this.activeStorageNames === null - ? PageOrContent.PAGE_AND_CONTENT - : this.activeStorageNames.get(sendData.storageName); + const activeOn = this.activeStorageNames.get(sendData.storageName); if (activeOn) { // 转发给 content 和 inject this.broadcastToPage("runtime/valueUpdate", sendData, activeOn); @@ -142,6 +142,16 @@ export default class ScriptingRuntime { break; } return false; + }, + (data) => { + const request = validatePageGMRequest(data, this.pageRpc); + return { + uuid: request.uuid, + api: request.api, + params: [...request.params], + runFlag: request.runFlag, + executionHandle: request.handle, + }; } ); } @@ -160,26 +170,41 @@ export default class ScriptingRuntime { client.pageLoad().then((o) => { if (!o.ok) return; const { injectScriptList, contentScriptList, envInfo } = o; + this.pageRpc.revokeAll(); + const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => + scripts.map((script) => { + const allowedAPIs = getPageRpcAllowedAPIs(script.metadata.grant || []); + const executionRunFlag = script.executionRunFlag || uuidv4(); + const executionHandle = + script.executionHandle || + this.pageRpc.register(script.uuid, envTag, allowedAPIs, undefined, executionRunFlag); + if (script.executionHandle) { + this.pageRpc.register(script.uuid, envTag, allowedAPIs, script.executionHandle, executionRunFlag); + } + return { ...script, executionHandle, executionEnvTag: envTag, executionRunFlag }; + }); + const preparedInjectScriptList = prepareScripts(injectScriptList, "it"); + const preparedContentScriptList = prepareScripts(contentScriptList, "ct"); const pairs = {} as Record; - for (const script of injectScriptList) { + for (const script of preparedInjectScriptList) { pairs[getStorageName(script)] |= PageOrContent.PAGE; } - for (const script of contentScriptList) { + for (const script of preparedContentScriptList) { pairs[getStorageName(script)] |= PageOrContent.CONTENT; } this.activeStorageNames = new Map(Object.entries(pairs)); // 向页面 发送脚本列表及环境信息 - if (contentScriptList.length) { + if (preparedContentScriptList.length) { const contentClient = new Client(this.senderToContent, "content"); // 根据@inject-into content过滤脚本 - contentClient.do("pageLoad", { scripts: contentScriptList, envInfo }); + contentClient.do("pageLoad", { scripts: preparedContentScriptList, envInfo }); } - if (injectScriptList.length) { + if (preparedInjectScriptList.length) { const injectClient = new Client(this.senderToInject, "inject"); // 根据@inject-into content过滤脚本 - injectClient.do("pageLoad", { scripts: injectScriptList, envInfo }); + injectClient.do("pageLoad", { scripts: preparedInjectScriptList, envInfo }); } }); } diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 64181c489..fe2e51a06 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -232,6 +232,9 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { delete scriptInfo.runStatus; // 前台脚本不用 delete scriptInfo.type; // 脚本类型总是普通脚本 delete scriptInfo.status; // 脚本状态总是启用 + delete scriptInfo.executionHandle; + delete scriptInfo.executionEnvTag; + delete scriptInfo.executionRunFlag; // --- 处理 scriptInfo --- return scriptInfo; }; @@ -249,6 +252,10 @@ export function compilePreInjectScript( const flag = `${script.flag}`; const scriptInfo = trimScriptInfo(script); const scriptInfoJSON = `${JSON.stringify(scriptInfo)}`; + const scriptUrlPatterns = script.scriptUrlPatterns?.map(({ ruleType, ruleContent }) => ({ ruleType, ruleContent })); + const urlCondition = scriptUrlPatterns + ? embeddedPatternCheckerString("location.href", JSON.stringify(scriptUrlPatterns)) + : "true"; const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; @@ -256,7 +263,7 @@ export function compilePreInjectScript( { let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, - f = () => performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), + f = () => ${urlCondition} && performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), needWait = f(); if (needWait) performance.addEventListener('${evEnvLoad}', f, { once: true }); } diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 63b937997..c2b811bfe 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -124,6 +124,36 @@ describe.concurrent("GM API 注册完整性", () => { }); }); +describe("page execution binding gate", () => { + it("rejects a page-originated request that has no binding handle", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab }); + + await expect( + api.handlerRequest({ uuid: "script-a", api: "GM_getTab", params: [], runFlag: "forged" }, sender) + ).rejects.toThrow("page execution binding is required"); + }); + + it("rejects an unknown page binding before parsing or invoking a GM API", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const resolveBinding = vi.fn().mockReturnValue(undefined); + Object.defineProperty(api, "resolvePageExecutionBinding", { configurable: true, value: resolveBinding }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab }); + + await expect( + api.handlerRequest( + { uuid: "script-a", api: "GM_getTab", params: [], runFlag: "forged", executionHandle: "missing" }, + sender + ) + ).rejects.toThrow("page execution binding is invalid"); + expect(resolveBinding).toHaveBeenCalledTimes(1); + }); +}); + describe("window.focus", () => { it("应同时激活标签页并将其所在窗口置于前台", async () => { const tabsUpdate = vi.fn().mockResolvedValue(undefined); diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 853ebc3aa..dcf4314bc 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -33,6 +33,7 @@ import type { MessageRequest, NotificationMessageOption, GMApiRequest, + ServiceWorkerExecutionBinding, } from "../types"; import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; import type { NotificationOptionCache } from "../utils"; @@ -361,7 +362,11 @@ export default class GMApi { private msgSender: MessageSend, private mq: IMessageQueue, private value: ValueService, - private gmExternalDependencies: IGMExternalDependencies + private gmExternalDependencies: IGMExternalDependencies, + private readonly resolvePageExecutionBinding?: ( + handle: string, + sender: IGetSender + ) => ServiceWorkerExecutionBinding | undefined ) { this.logger = LoggerCore.logger().with({ service: "runtime/gm_api" }); } @@ -374,6 +379,27 @@ export default class GMApi { // sendMessage from Content Script, etc async handlerRequest(data: MessageRequest, sender: IGetSender) { this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params }); + const source = sender.getSender(); + const isPageRequest = typeof source?.tab?.id === "number"; + if (isPageRequest && !data.executionHandle) { + throw new Error("page execution binding is required"); + } + if (data.executionHandle) { + if (data.version !== undefined && data.version !== 1) { + throw new Error("unsupported page execution binding version"); + } + if (data.handle !== undefined && data.handle !== data.executionHandle) { + throw new Error("page execution binding is invalid"); + } + const binding = this.resolvePageExecutionBinding?.(data.executionHandle, sender); + if (!binding || (data.uuid && data.uuid !== binding.uuid)) { + throw new Error("page execution binding is invalid"); + } + if (data.envTag !== undefined && data.envTag !== binding.envTag) { + throw new Error("page execution binding is invalid"); + } + data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; + } const api = PermissionVerifyApiGet(data.api); if (!api) { throw new Error("gm api is not found"); diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index dbe139672..a393a6aba 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -484,6 +484,7 @@ export default class ServiceWorkerManager { // 无视错误 } onTabRemoved(tabId); + runtime.revokePageBindingsForTab(tabId); }); } } diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index fc272def4..51b9f948a 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1109,6 +1109,59 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { url: "https://www.example.com/page", }); }); + + it("为每个页面文档签发绑定,并拒绝跨标签页、跨 frame 和旧文档复用", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "bound-script", metadata: { grant: ["GM_getTab"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sender = new SenderRuntime(rawSender); + const first = await runtime.pageLoad(undefined, sender); + const firstHandle = first.ok ? first.injectScriptList[0].executionHandle : undefined; + const firstRunFlag = first.ok ? first.injectScriptList[0].executionRunFlag : undefined; + expect(firstHandle).toEqual(expect.any(String)); + expect(firstRunFlag).toEqual(expect.any(String)); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toMatchObject({ + uuid: "bound-script", + envTag: "it", + tabId: 41, + frameId: 0, + documentId: "doc-a", + }); + + const otherTab = new SenderRuntime({ ...rawSender, tab: { ...rawSender.tab, id: 42 } as chrome.tabs.Tab }); + const otherFrame = new SenderRuntime({ ...rawSender, frameId: 1 }); + expect(runtime.resolvePageExecutionBinding(firstHandle!, otherTab)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(firstHandle!, otherFrame)).toBeUndefined(); + + const secondSender = new SenderRuntime({ ...rawSender, documentId: "doc-b" }); + const second = await runtime.pageLoad(undefined, secondSender); + const secondHandle = second.ok ? second.injectScriptList[0].executionHandle : undefined; + const secondRunFlag = second.ok ? second.injectScriptList[0].executionRunFlag : undefined; + expect(secondHandle).toEqual(expect.any(String)); + expect(secondRunFlag).toEqual(expect.any(String)); + expect(secondHandle).not.toBe(firstHandle); + expect(secondRunFlag).not.toBe(firstRunFlag); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeDefined(); + expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeDefined(); + + runtime.revokePageBindingsForTab(41); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); + }); }); describe("sandbox verified 初始化重放", () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index d44ef0215..77228c91b 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1,4 +1,10 @@ -import type { EmitEventRequest, ScriptLoadInfo, ScriptMatchInfo, ScriptMenu } from "./types"; +import type { + EmitEventRequest, + ScriptLoadInfo, + ScriptMatchInfo, + ScriptMenu, + ServiceWorkerExecutionBinding, +} from "./types"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { Group, IGetSender } from "@Packages/message/server"; import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; @@ -60,6 +66,7 @@ import { CompiledResourceDAO, CompiledResourceNamespace } from "@App/app/repo/re import { setOnTabURLChanged } from "./url_monitor"; import { scriptToMenu, type TPopupPageLoadInfo, type TPopupPageRestoreInfo } from "./popup_scriptmenu"; import { getExtensionUserAgentData } from "../extension/extension_env"; +import { uuidv4 } from "@App/pkg/utils/uuid"; const ORIGINAL_URLMATCH_SUFFIX = "{ORIGINAL}"; // 用于标记原始URLPatterns的后缀 @@ -134,11 +141,67 @@ export class RuntimeService { scriptMatchEnable: UrlMatch = new UrlMatch(); blackMatch: UrlMatch = new UrlMatch(); private gmApi?: GMApi; + private readonly pageExecutionBindings = new Map(); getGMApi(): GMApi | undefined { return this.gmApi; } + private revokePageBindings(sender: IGetSender): void { + const source = sender.getSender(); + const tabId = source?.tab?.id; + const frameId = source?.frameId; + const documentId = source?.documentId; + for (const [handle, binding] of this.pageExecutionBindings) { + if ( + binding.tabId === tabId && + binding.frameId === frameId && + (documentId === undefined || binding.documentId === documentId) + ) { + this.pageExecutionBindings.delete(handle); + } + } + } + + revokePageBindingsForTab(tabId: number): void { + for (const [handle, binding] of this.pageExecutionBindings) { + if (binding.tabId === tabId) this.pageExecutionBindings.delete(handle); + } + } + + private revokePageBindingsForScript(uuid: string): void { + for (const [handle, binding] of this.pageExecutionBindings) { + if (binding.uuid === uuid) this.pageExecutionBindings.delete(handle); + } + } + + private issuePageBinding(uuid: string, envTag: "it" | "ct", sender: IGetSender): ServiceWorkerExecutionBinding { + const source = sender.getSender(); + const tabId = source?.tab?.id; + if (typeof tabId !== "number") throw new Error("page execution binding requires a tab"); + const handle = uuidv4(); + const binding = { + handle, + uuid, + envTag, + runFlag: uuidv4(), + tabId, + frameId: source?.frameId, + documentId: source?.documentId, + } satisfies ServiceWorkerExecutionBinding; + this.pageExecutionBindings.set(handle, binding); + return binding; + } + + resolvePageExecutionBinding(handle: string, sender: IGetSender): ServiceWorkerExecutionBinding | undefined { + const binding = this.pageExecutionBindings.get(handle); + const source = sender.getSender(); + if (!binding || !source?.tab || source.tab.id !== binding.tabId || source.frameId !== binding.frameId) + return undefined; + if (binding.documentId !== undefined && source.documentId !== binding.documentId) return undefined; + return binding; + } + private readonly disabledMatcherTaskKey = `runtime_disabled_matcher:${Math.random()}`; private disabledMatcher: UrlMatch | null = null; private disabledMatcherVersion = 0; @@ -527,7 +590,8 @@ export class RuntimeService { this.msgSender, this.mq, this.value, - new GMExternalDependencies(this) + new GMExternalDependencies(this), + this.resolvePageExecutionBinding.bind(this) ); permission.init(); this.gmApi.start(); @@ -548,6 +612,7 @@ export class RuntimeService { const unregisterUuids = [] as string[]; for (const { uuid, enable } of data) { + this.revokePageBindingsForScript(uuid); const script = await this.scriptDAO.get(uuid); if (!script) { this.logger.error("script enable failed, script not found", { @@ -582,6 +647,7 @@ export class RuntimeService { // 监听脚本安装 this.mq.subscribe("installScript", async (data) => { const uuid = data.script.uuid; + this.revokePageBindingsForScript(uuid); this.invalidateDisabledMatcher(); this.deleteScriptRuntimeCache(uuid); @@ -620,6 +686,7 @@ export class RuntimeService { const unregisterUuids = [] as string[]; this.updateSorter((next) => { for (const { uuid } of data) { + this.revokePageBindingsForScript(uuid); unregisterUuids.push(uuid); this.deleteScriptRuntimeCache(uuid); this.deleteScriptSort(next, uuid); @@ -844,6 +911,7 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { + this.pageExecutionBindings.clear(); // 检查 registered 避免重复操作增加系统开支 // 已成功注册(true)或是未知有无注册(null)的情况下执行 if (runtimeGlobal.registerState !== RuntimeRegisterCode.UNREGISTER_DONE) { @@ -1286,11 +1354,22 @@ export class RuntimeService { }); if (res) { + this.revokePageBindings(sender); + const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => + scripts.map((script) => { + const binding = this.issuePageBinding(script.uuid, envTag, sender); + return { + ...script, + executionHandle: binding.handle, + executionEnvTag: envTag, + executionRunFlag: binding.runFlag, + }; + }); // 返回脚本资料,在页面加载 return { ok: true, - injectScriptList: res.injectScriptList, - contentScriptList: res.contentScriptList, + injectScriptList: prepareScripts(res.injectScriptList, "it"), + contentScriptList: prepareScripts(res.contentScriptList, "ct"), envInfo: res.envInfo, }; } else { diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 5197fa039..00d686bdd 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -46,6 +46,23 @@ export type MessageRequest = { api: string; runFlag: string; params: T; + /** 页面执行环境绑定的能力句柄;后台脚本不携带此字段。 */ + executionHandle?: string; + /** 页面 GM RPC 的版本和请求关联字段。 */ + version?: 1; + requestId?: string; + handle?: string; + envTag?: "it" | "ct"; +}; + +export type ServiceWorkerExecutionBinding = { + handle: string; + uuid: string; + envTag: "it" | "ct"; + runFlag: string; + tabId: number; + frameId?: number; + documentId?: string; }; export type GMApiRequest = MessageRequest & { diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 94c31b627..57d2df7e6 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { isBase64, parseUrlSRI, @@ -6,14 +6,16 @@ import { selfMetadataUpdate, getUserScriptRegister, compileInjectionCode, + parseScriptLoadInfo, shouldAutoOpenChangelog, scriptURLPatternResults, } from "./utils"; -import type { SCMetadata, Script, ScriptRunResource } from "@App/app/repo/scripts"; +import type { SCMetadata, ScriptLoadInfo, Script } from "@App/app/repo/scripts"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts"; import type { ScriptMatchInfo } from "./types"; import { extractUrlPatterns, RuleTypeBit } from "@App/pkg/utils/url_matcher"; +import { compilePreInjectScript } from "../content/utils"; describe.concurrent("parseUrlSRI", () => { it.concurrent("should parse URL SRI", () => { @@ -311,7 +313,7 @@ describe.concurrent("getUserScriptRegister", () => { }); describe.concurrent("compileInjectionCode", () => { - const createMockScriptRes = (overrides: Partial = {}): ScriptRunResource => ({ + const createMockScriptRes = (overrides: Partial = {}): ScriptLoadInfo => ({ uuid: "test-uuid", name: "Test Script", namespace: "test.namespace", @@ -327,6 +329,8 @@ describe.concurrent("compileInjectionCode", () => { resource: {}, metadata: {}, originalMetadata: {}, + metadataStr: "", + userConfigStr: "", ...overrides, }); @@ -358,6 +362,29 @@ describe.concurrent("compileInjectionCode", () => { // 使用 compileInjectScript 包裹(window[flag] = function(){...}) expect(result).toContain("window['#-test-uuid']"); }); + + it.concurrent("预注入脚本在派发事件前执行精确 URL 规则", () => { + const scriptRes = createMockScriptRes({ + metadata: { "early-start": [""], "run-at": ["document-start"] }, + scriptUrlPatterns: extractUrlPatterns(["@include /example\\.com/"]), + }); + const result = compilePreInjectScript(parseScriptLoadInfo(scriptRes, scriptRes.scriptUrlPatterns ?? []), "", false); + const dispatchEvent = vi.fn(() => true); + const performance = { dispatchEvent, addEventListener: vi.fn() }; + const customEvent = class { + constructor( + readonly type: string, + readonly init: unknown + ) {} + }; + const run = new Function("window", "performance", "CustomEvent", "location", result); + + run(Object.create(null), performance, customEvent, { href: "https://other.example/" }); + expect(dispatchEvent).not.toHaveBeenCalled(); + + run(Object.create(null), performance, customEvent, { href: "https://example.com/" }); + expect(dispatchEvent).toHaveBeenCalledTimes(1); + }); }); describe.concurrent("scriptURLPatternResults", () => { From a6e69ed64420507d8c68714647cabb69983d3a88 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:39:01 +0900 Subject: [PATCH 002/106] =?UTF-8?q?=F0=9F=90=9B=20allow=20async=20GM=20XHR?= =?UTF-8?q?=20page=20RPC=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 6 ++++++ src/app/service/content/page_rpc.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 39cb74802..66586d1a2 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -24,6 +24,12 @@ describe("page GM RPC", () => { expect(allowed).toEqual(expect.arrayContaining(["GM.openInTab", "GM_openInTab", "GM_closeInTab"])); }); + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { + const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); + + expect(allowed).toContain("GM_xmlhttpRequest"); + }); + it("accepts a request for the active execution binding and clones parameters", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"], undefined, "canonical-run"); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 2c6e18e9b..c3c83f3ee 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -35,7 +35,7 @@ const INTERNAL_APIS_BY_GRANT: Readonly> = { CAT_fileStorage: ["CAT_fetchBlob", "CAT_createBlobUrl"], GM_xmlhttpRequest: ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], "GM.xmlhttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], - "GM.xmlHttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], + "GM.xmlHttpRequest": ["GM_xmlhttpRequest", "CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], }; // ScriptingRuntime does not load the GM implementation module, so mirror its small dependency graph here. From ef945e820e5cbf3973b05d95049f818c04e04206 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:24:18 +0900 Subject: [PATCH 003/106] =?UTF-8?q?=F0=9F=94=92=20harden=20userscript=20in?= =?UTF-8?q?vocation=20against=20page=20substitution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/common.ts | 11 +- packages/message/server.ts | 13 +- rspack.config.ts | 2 + .../service/content/create_context.test.ts | 19 ++ src/app/service/content/create_context.ts | 21 +- src/app/service/content/exec_script.test.ts | 16 ++ src/app/service/content/exec_script.ts | 4 +- src/app/service/content/global.ts | 27 ++- src/app/service/content/gm_api/gm_api.test.ts | 4 +- src/app/service/content/gm_api/gm_api.ts | 8 +- .../content/gm_api/navigation_handle.test.ts | 17 ++ .../content/gm_api/navigation_handle.ts | 4 +- .../service/content/script_executor.test.ts | 52 +++++ src/app/service/content/script_executor.ts | 29 ++- src/app/service/content/types.ts | 2 +- src/app/service/content/utils.test.ts | 184 +++++++++++++++++- src/app/service/content/utils.ts | 61 ++++-- src/app/service/service_worker/utils.test.ts | 6 +- vitest.config.ts | 2 + 19 files changed, 416 insertions(+), 66 deletions(-) diff --git a/packages/message/common.ts b/packages/message/common.ts index 009fd8a51..33b3e6757 100644 --- a/packages/message/common.ts +++ b/packages/message/common.ts @@ -8,9 +8,14 @@ export const CustomEventClone = CustomEvent; const performanceClone = (process.env.VI_TESTING === "true" ? new EventTarget() : performance) as Performance; // 避免页面载入后改动 EventTarget.prototype 的方法导致消息传递失败 -export const pageDispatchEvent = performanceClone.dispatchEvent.bind(performanceClone); -export const pageAddEventListener = performanceClone.addEventListener.bind(performanceClone); -export const pageRemoveEventListener = performanceClone.removeEventListener.bind(performanceClone); +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +export const pageDispatchEvent = bindNative(performanceClone.dispatchEvent, performanceClone); +export const pageAddEventListener = bindNative(performanceClone.addEventListener, performanceClone); +export const pageRemoveEventListener = bindNative(performanceClone.removeEventListener, performanceClone); const detailClone = typeof cloneInto === "function" ? cloneInto : null; export const pageDispatchCustomEvent = (eventType: string, detail: T) => { if (detailClone && detail) detail = detailClone(detail, performanceClone); diff --git a/packages/message/server.ts b/packages/message/server.ts index 4a62f824f..0c4682c23 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -4,6 +4,11 @@ import { connect, sendMessage } from "./client"; import { ExtensionMessageConnect } from "./extension_message"; import Logger from "@App/app/logger/logger"; +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + export const enum GetSenderType { CONNECT = 1, EXTCONNECT = 1 | 2, @@ -300,10 +305,10 @@ export function forwardMessage( const fromConnect: MessageConnect | undefined = fromCon.getConnect(); if (fromConnect) { const toCon: MessageConnect = await connect(senderTo, `${prefix}/${path}`, params); - fromConnect.onMessage(toCon.sendMessage.bind(toCon)); - toCon.onMessage(fromConnect.sendMessage.bind(fromConnect)); - fromConnect.onDisconnect(toCon.disconnect.bind(toCon)); - toCon.onDisconnect(fromConnect.disconnect.bind(fromConnect)); + fromConnect.onMessage(bindNative(toCon.sendMessage, toCon)); + toCon.onMessage(bindNative(fromConnect.sendMessage, fromConnect)); + fromConnect.onDisconnect(bindNative(toCon.disconnect, toCon)); + toCon.onDisconnect(bindNative(fromConnect.disconnect, fromConnect)); return undefined; } else { return sendMessage(senderTo, prefix + "/" + path, params); diff --git a/rspack.config.ts b/rspack.config.ts index f9e8ae578..35d02137b 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,6 +138,8 @@ export default { new rspack.DefinePlugin({ "process.env.VI_TESTING": "'false'", "process.env.SC_RANDOM_KEY": `'${uuidv4()}'`, + "process.env.SC_RANDOM_FNKEY": `'${uuidv4()}'`, + "process.env.SC_ZN_RAND": `'$${uuidv4()}'`, "process.env.SC_DISABLE_AGENT": `'${enableAgent ? "false" : "true"}'`, }), new rspack.CopyRspackPlugin({ diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index a5eb5bc00..f5ccee32b 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -938,6 +938,25 @@ describe("createProxyContext: deterministic realm contract", () => { expect(third).toHaveBeenCalledTimes(1); }); + it("事件 callback 的 call 屬性被頁面改寫時仍保留 sandbox this", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const handler = vi.fn(function (this: unknown) { + expect(this).toBe(sandbox); + }); + Object.defineProperty(handler, "call", { + configurable: true, + value: () => { + throw new Error("poisoned call"); + }, + }); + + sandbox.onload = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + + expect(handler).toHaveBeenCalledTimes(1); + }); + it("split realm 下 self/window/globalThis 寫入都留在當前 sandbox", () => { const fixture = createSplitRealmRoots(); const sandbox = createProxyContext(Object.create(null), fixture.roots); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index b9877fd6d..0fb81cbf2 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -8,11 +8,10 @@ import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; import { createGMBase } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; - -const nativeReflectApply = Reflect.apply; +import { Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { - const capability = (...args: any[]) => nativeReflectApply(api, receiver, args); + const capability = (...args: any[]) => Native.reflectApply(api, receiver, args); Object.defineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); return capability; }; @@ -186,21 +185,19 @@ const isConstructorOrInterface = (value: unknown) => { }; // 避免 host/Xray function 的 .bind lookup 不可靠 -const bindFn = Function.prototype.bind; - const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): PropertyDescriptor => { if ("value" in descriptor) { if (typeof descriptor.value !== "function" || isConstructorOrInterface(descriptor.value)) return descriptor; return { ...descriptor, - value: nativeReflectApply(bindFn, descriptor.value, [receiver]), + value: Native.bind(descriptor.value, receiver), }; } if (!descriptor.get && !descriptor.set) return descriptor; return { ...descriptor, - get: descriptor.get ? nativeReflectApply(bindFn, descriptor.get, [receiver]) : undefined, - set: descriptor.set ? nativeReflectApply(bindFn, descriptor.set, [receiver]) : undefined, + get: descriptor.get ? Native.bind(descriptor.get, receiver) : undefined, + set: descriptor.set ? Native.bind(descriptor.set, receiver) : undefined, }; }; @@ -371,8 +368,8 @@ export const createProxyContext = ( // mySandbox: ScriptCat各脚本独自使用 let mySandbox: typeof sharedInitCopy | undefined = undefined; - const hostAddEventListener = roots.hostWindow.addEventListener.bind(roots.hostWindow); - const hostRemoveEventListener = roots.hostWindow.removeEventListener.bind(roots.hostWindow); + const hostAddEventListener = Native.bind(roots.hostWindow.addEventListener, roots.hostWindow); + const hostRemoveEventListener = Native.bind(roots.hostWindow.removeEventListener, roots.hostWindow); // 用 eventHandling 机制模拟 onxxxxxxx 事件设置 // 监听事件实际上的方法是eventObject.handleEvent @@ -387,7 +384,7 @@ export const createProxyContext = ( hostRemoveEventListener(eventName, eventObject); this.fn = null; } else { - fn.call(mySandbox, event); + Native.call(fn, mySandbox, event); } }, }; @@ -525,7 +522,7 @@ export const createProxyContext = ( const handle = function (this: Window & Record, e: UrlChangeEvent) { this.onurlchange?.(e); } as EventListener; - (roots.hostWindow).addEventListener("urlchange", handle.bind(mySandbox), false); + (roots.hostWindow).addEventListener("urlchange", Native.bind(handle, mySandbox), false); } // 从网页 console 隔离出来的沙盒 console diff --git a/src/app/service/content/exec_script.test.ts b/src/app/service/content/exec_script.test.ts index 528999b35..01d9e9299 100644 --- a/src/app/service/content/exec_script.test.ts +++ b/src/app/service/content/exec_script.test.ts @@ -68,6 +68,22 @@ describe.concurrent("GM_info", () => { expect(ret.GM_info.script.version).toEqual("1.0.0"); expect(ret._this).not.toEqual(global); }); + + it.concurrent("does not resolve a mutable script function call property", async () => { + const { exec } = makeExec("return this;"); + const scriptFunc = function (_token: string, context: unknown) { + return context; + } as ScriptFunc & { call?: unknown }; + Object.defineProperty(scriptFunc, "call", { + configurable: true, + value: () => { + throw new Error("poisoned call"); + }, + }); + exec.scriptFunc = scriptFunc; + + expect(await exec.exec()).toBe(exec.execContext); + }); }); describe.concurrent("unsafeWindow", () => { diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index dc76d462b..febd9f01e 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -9,6 +9,8 @@ import { evaluateGMInfo } from "./gm_api/gm_info"; import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; + // 执行脚本,控制脚本执行与停止 export default class ExecScript { scriptRes: TScriptInfo; @@ -88,7 +90,7 @@ export default class ExecScript { this.logger.debug("script start"); const sandboxContext = this.sandboxContext; this.execContext = sandboxContext ? createProxyContext(sandboxContext) : global; // this.$ 只能执行一次 - return this.scriptFunc.call(this.execContext, this.named, this.scriptRes.name); + return this.scriptFunc(fnStrIntegrity, this.execContext, this.named, this.scriptRes.name); }; // 早期启动的脚本,处理GM API diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 3b78911ff..a431a025c 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -3,16 +3,31 @@ const unsupportedAPI = () => { throw "unsupportedAPI"; }; +// 在页面或用户脚本替换调用内建函数前完成捕获。 +export const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; + +export const nativeApply = (fn: (...args: any[]) => any, receiver: any, args: any[]) => + nativeReflectApply(fn, receiver, args); +export const nativeCall = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => + nativeReflectApply(fn, receiver, args); +export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => + nativeReflectApply(nativeFunctionBind, fn, [receiver, ...args]); + export const Native = { + apply: nativeApply, + call: nativeCall, + bind: nativeBind, + reflectApply: nativeReflectApply, structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, - jsonStringify: JSON.stringify.bind(JSON), - jsonParse: JSON.parse.bind(JSON), + jsonStringify: nativeBind(JSON.stringify, JSON), + jsonParse: nativeBind(JSON.parse, JSON), createElement: Document.prototype.createElement, ownFragment: new DocumentFragment(), - objectCreate: Object.create.bind(Object), - objectGetOwnPropertyDescriptors: Object.getOwnPropertyDescriptors.bind(Object), - objectGetOwnPropertyDescriptor: Object.getOwnPropertyDescriptor.bind(Object), - objectGetPrototypeOf: Object.getPrototypeOf.bind(Object), + objectCreate: nativeBind(Object.create, Object), + objectGetOwnPropertyDescriptors: nativeBind(Object.getOwnPropertyDescriptors, Object), + objectGetOwnPropertyDescriptor: nativeBind(Object.getOwnPropertyDescriptor, Object), + objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), } as const; export const customClone = (o: any) => { 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 48f9cbce2..8be773dad 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -82,8 +82,8 @@ describe("early-start page RPC", () => { envInfo, }); - exec.scriptFunc = function (this: any) { - return this.GM.setValue("a", 123); + exec.scriptFunc = function (_token: string, context: any) { + return context.GM.setValue("a", 123); } as unknown as ScriptFunc; const result = exec.exec(); await Promise.resolve(); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 450f23ced..29e811bd7 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1321,7 +1321,7 @@ export default class GMApi extends GM_Base { gmApi.sendMessage("GM_notification", [customClone(data), notificationId]).then((id) => { if (!gmApi.EE) return; if (create) { - create.apply({ id }, [id]); + Native.apply(create, { id }, [id]); } if (typeof data.tag === "string") { notificationTagMap.set(data.tag, id); @@ -1358,8 +1358,8 @@ export default class GMApi extends GM_Base { title: data.title, url: data.url, }; - click && click.apply({ id }, [clickEvent]); - done && done.apply({ id }, []); + click && Native.apply(click, { id }, [clickEvent]); + done && Native.apply(done, { id }, []); if (!isPreventDefault) { if (typeof data.url === "string") { @@ -1372,7 +1372,7 @@ export default class GMApi extends GM_Base { break; } case "close": { - done && done.apply({ id }, [resp.params.byUser]); + done && Native.apply(done, { id }, [resp.params.byUser]); clearNotificationIdMap(); gmApi.EE.removeAllListeners("GM_notification:" + gmApi.eventId); break; diff --git a/src/app/service/content/gm_api/navigation_handle.test.ts b/src/app/service/content/gm_api/navigation_handle.test.ts index 29d7dfbdd..5058be5d6 100644 --- a/src/app/service/content/gm_api/navigation_handle.test.ts +++ b/src/app/service/content/gm_api/navigation_handle.test.ts @@ -94,6 +94,23 @@ describe("attachNavigateHandler", () => { expect(ev.url).toBe("https://example.com/new"); }); + it("dispatchEvent 的 bind 屬性被改寫時仍能派發事件", async () => { + const mock = createMockWin("https://example.com/"); + Object.defineProperty(mock.win.dispatchEvent, "bind", { + configurable: true, + value: () => { + throw new Error("poisoned bind"); + }, + }); + + attachNavigateHandler(mock.win); + mock.fireNavigate("https://example.com/new"); + + await vi.waitFor(() => { + expect(mock.dispatched).toHaveLength(1); + }); + }); + it("URL 未变化时不应派发事件", async () => { const mock = createMockWin("https://example.com/"); attachNavigateHandler(mock.win); diff --git a/src/app/service/content/gm_api/navigation_handle.ts b/src/app/service/content/gm_api/navigation_handle.ts index a536f6a70..d05beebe5 100644 --- a/src/app/service/content/gm_api/navigation_handle.ts +++ b/src/app/service/content/gm_api/navigation_handle.ts @@ -19,7 +19,7 @@ const getPropGetter = (obj: T, key: keyof T) => { // 避免直接 obj[key] 读取。或会被 hack for (let t = obj; t; t = Native.objectGetPrototypeOf(t)) { const pd = Native.objectGetOwnPropertyDescriptor(t, key); - if (pd) return pd.get?.bind(obj); + if (pd) return pd.get ? Native.bind(pd.get, obj) : undefined; } }; @@ -33,7 +33,7 @@ export const attachNavigateHandler = (win: Window & { navigation: EventTarget }) // 以 location.href 判断避免 replaceState/pushState 重复执行重复触发 const loc = win.location; const getUrl = getPropGetter(loc, "href"); - const dispatch = win.dispatchEvent.bind(win); + const dispatch = Native.bind(win.dispatchEvent, win); let lastUrl = getUrl?.(); let callSeq = 0; const handler = async (ev: Event): Promise => { diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index e8ea976e1..1d4b8ff97 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -7,6 +7,7 @@ import { initEnvInfo, ScriptExecutor } from "./script_executor"; const styleUrl = "https://example.com/style.css"; const secondStyleUrl = "https://example.com/second-style.css"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; function makeScript(overrides: Partial> = {}): ScriptLoadInfo { return { @@ -98,6 +99,57 @@ describe("ScriptExecutor", () => { expect(exec.scriptRes.executionEnvTag).toBe("it"); }); + it("ignores a counterfeit mount and keeps listening for the genuine wrapper", () => { + const script = makeScript({ flag: "executor-counterfeit-flag" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attackerTarget = vi.fn(); + const attacker = new Proxy(attackerTarget, { + getOwnPropertyDescriptor(target, property) { + if (property === fnStrIntegrity) { + return { configurable: true, enumerable: false, value: true, writable: true }; + } + return Object.getOwnPropertyDescriptor(target, property); + }, + }); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + executor.startScripts([script], initEnvInfo); + pageWindow[script.flag] = attacker; + + expect(attackerTarget).not.toHaveBeenCalled(); + + pageWindow[script.flag] = genuine; + + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects a counterfeit early-start wrapper before execution", () => { + const script = makeScript({ flag: "executor-counterfeit-early-flag" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attacker = vi.fn(); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + pageWindow[script.flag] = attacker; + executor.execEarlyScript(script.flag, script, initEnvInfo); + expect(attacker).not.toHaveBeenCalled(); + + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, script, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + describe("resource execution", () => { let adoptedSheets: CSSStyleSheet[]; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 02df2320e..32e9cb7d1 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -9,7 +9,9 @@ import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common"; import { isUrlExcluded } from "@App/pkg/utils/match"; import type { ScriptEnvTag } from "@Packages/message/consts"; -import { localizeObject } from "./global"; +import { localizeObject, Native } from "./global"; + +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; export type ExecScriptEntry = { scriptLoadInfo: TScriptInfo; @@ -61,6 +63,7 @@ export class ScriptExecutor { } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { + const pageWindow = window as unknown as Record; const loadExec = (script: TScriptInfo, scriptFunc: any) => { this.execScriptEntry({ scriptLoadInfo: script, @@ -90,9 +93,22 @@ export class ScriptExecutor { } } } - definePropertyListener(window, flag, (val: ScriptFunc) => { - loadExec(script, val); - }); + const listenForScript = () => { + definePropertyListener(window, flag, (val: ScriptFunc) => { + const descriptor = + typeof val === "function" ? Native.objectGetOwnPropertyDescriptor(val, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) { + const mountDescriptor = Native.objectGetOwnPropertyDescriptor(pageWindow, flag); + if (mountDescriptor?.configurable) { + delete pageWindow[flag]; + listenForScript(); + } + return; + } + loadExec(script, val); + }); + }; + listenForScript(); }); } @@ -153,7 +169,10 @@ export class ScriptExecutor { } execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { - const scriptFunc = (window as any)[flag] as ScriptFunc; + const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; + const descriptor = + typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) return; this.execScriptEntry({ scriptLoadInfo: scriptInfo, scriptFunc: scriptFunc, diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..61d0f060f 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -1,6 +1,6 @@ import type { REncoded } from "@App/pkg/utils/message_value"; -export type ScriptFunc = (named: { [key: string]: any } | undefined, scriptName: string) => any; +export type ScriptFunc = (s: string, ctx: any, named: { [key: string]: any } | undefined, scriptName: string) => any; // exec_script.ts diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 837728eec..51ca3b255 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -3,6 +3,7 @@ import { compileScriptCode, compileScript, compileInjectScript, + compilePreInjectScript, compileScriptletCode, isScriptletUnwrap, addStyle, @@ -13,6 +14,24 @@ import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/rep import type { ScriptFunc } from "./types"; import { RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; +const znRand = process.env.SC_ZN_RAND!; + +type GeneratedWindow = Record; + +function executeGeneratedScript( + code: string, + targetWindow: GeneratedWindow, + testPerformance: Pick = globalThis.performance +) { + const execute = new Function("window", "performance", "CustomEvent", code) as ( + window: GeneratedWindow, + performance: Pick, + customEvent: typeof CustomEvent + ) => void; + execute(targetWindow, testPerformance, globalThis.CustomEvent); +} + // 设置 console mock 来避免测试输出污染 vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -60,7 +79,8 @@ describe("utils", () => { expect(result).toContain("try {"); expect(result).toContain("} catch (e) {"); expect(result).toContain("with(arguments[0]||this.$)"); - expect(result).toContain("return(async function(){"); + expect(result).toContain("this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){"); + expect(result).toContain("return this[arguments[0]](...((delete this[arguments[0]]),[]));"); }); it.concurrent("应该处理自定义脚本代码参数", () => { @@ -495,7 +515,7 @@ describe("utils", () => { const code = "return arguments[0].value + arguments[1];"; const func: ScriptFunc = compileScript(code); - const result = func({ value: 10 }, "test-script"); + const result = func(fnStrIntegrity, {}, { value: 10 }, "test-script"); expect(result).toBe("10test-script"); }); @@ -511,8 +531,8 @@ describe("utils", () => { `; const func: ScriptFunc = compileScript(code); - const result1 = func({ value: 5, multiply: 3 }, "test"); - const result2 = func({ value: 5 }, "fallback"); + const result1 = func(fnStrIntegrity, {}, { value: 5, multiply: 3 }, "test"); + const result2 = func(fnStrIntegrity, {}, { value: 5 }, "fallback"); expect(result1).toBe(15); expect(result2).toBe("fallback"); @@ -526,7 +546,7 @@ describe("utils", () => { `; const func: ScriptFunc = compileScript(code); - const result = await func({ value: 5 }, "async-test"); + const result = await func(fnStrIntegrity, {}, { value: 5 }, "async-test"); expect(result).toBe(10); }); @@ -535,7 +555,13 @@ describe("utils", () => { const code = "throw new Error('Test error');"; const func: ScriptFunc = compileScript(code); - expect(() => func({}, "error-test")).toThrow("Test error"); + expect(() => func(fnStrIntegrity, {}, {}, "error-test")).toThrow("Test error"); + }); + + it.concurrent("完整性标记不匹配时不应执行脚本", () => { + const func: ScriptFunc = compileScript("throw new Error('should not run');"); + + expect(func("invalid", {}, {}, "blocked")).toBeUndefined(); }); }); @@ -559,13 +585,48 @@ describe("utils", () => { ...overrides, }); + it("生成的腳本包裝不依賴被 require 內容改寫的 Function.prototype 调用方法", async () => { + const script = createMockScript({ + code: "return this;", + resource: { + library: { + url: "https://example.com/library.js", + content: + "Function.prototype.call = Function.prototype.apply = Function.prototype.bind = () => { throw new Error('poisoned invocation'); };", + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "require", + link: {}, + contentType: "text/javascript", + createtime: Date.now(), + }, + }, + metadata: { require: ["library"] }, + }); + const func = compileScript(compileScriptCode(script)); + const originalCall = Function.prototype.call; + const originalApply = Function.prototype.apply; + const originalBind = Function.prototype.bind; + let result: unknown; + try { + result = await func(fnStrIntegrity, globalThis, {}, script.name); + } finally { + Function.prototype.call = originalCall; + Function.prototype.apply = originalApply; + Function.prototype.bind = originalBind; + } + expect(result).toBe(globalThis); + }); + it.concurrent("应该生成基本的注入脚本代码", () => { const script = createMockScript(); const scriptCode = "console.log('injected');"; const result = compileInjectScript(script, scriptCode); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('injected');}`); + expect(result).toBe( + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('injected');}));` + ); }); it.concurrent("应该包含自动删除挂载函数的代码", () => { @@ -577,7 +638,7 @@ describe("utils", () => { expect(result).toContain(`try{delete window['inject-test-flag']}catch(e){}`); expect(result).toContain("console.log('with auto delete');"); expect(result).toBe( - `window['inject-test-flag'] = function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}` + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}));` ); }); @@ -588,7 +649,64 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); expect(result).not.toContain("try{delete window"); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('without auto delete');}`); + expect(result).toBe( + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('without auto delete');}));` + ); + }); + + it.concurrent("生成的注入脚本应在运行时传递上下文和参数,并清理临时挂载", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + const context = {}; + const named = { value: 42 }; + + executeGeneratedScript( + compileInjectScript( + script, + "return { thisValue: this, args: Array.from(arguments), contextKeys: Reflect.ownKeys(this) };" + ), + targetWindow + ); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ + thisValue: context, + args: [named, script.name], + contextKeys: [], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + }); + + it.concurrent("生成的注入脚本应拒绝错误的完整性标记", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "throw new Error('should not run');"), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated("invalid", {}, {}, "blocked")).toBeUndefined(); + }); + + it.concurrent("生成的注入脚本应按选项自动删除挂载函数", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "return 'ran';", true), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, {}, {}, script.name)).toBe("ran"); + expect(targetWindow[script.flag]).toBeUndefined(); + }); + + it.concurrent("生成的注入脚本默认应保留挂载函数", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "return 'ran';"), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, {}, {}, script.name)).toBe("ran"); + expect(targetWindow[script.flag]).toBeUndefined(); }); it.concurrent("应该处理复杂的脚本代码", () => { @@ -613,7 +731,53 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); - expect(result).toContain(`window['flag-with-special-chars_123']`); + expect(result).toContain(`'flag-with-special-chars_123'`); + }); + }); + + describe("compilePreInjectScript", () => { + it.concurrent("生成的预注入脚本应可执行并发出脚本加载事件", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-test-uuid", + name: "Pre Inject Test Script", + namespace: "pre.inject.test", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "pre-inject-test-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + }; + const targetWindow: GeneratedWindow = {}; + const testPerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + + executeGeneratedScript( + compilePreInjectScript(script, "return { thisValue: this, args: Array.from(arguments) };"), + targetWindow, + testPerformance + ); + + const generated = targetWindow[script.flag] as ScriptFunc; + const context = {}; + const named = { value: 42 }; + expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ + thisValue: context, + args: [named, script.name], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + expect(testPerformance.dispatchEvent).toHaveBeenCalledTimes(1); + expect(testPerformance.addEventListener).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index fe2e51a06..d07a2161c 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -8,6 +8,9 @@ import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEn import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; +const znRand = process.env.SC_ZN_RAND; + export type CompileScriptCodeResource = { name: string; code: string; @@ -141,7 +144,7 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) // arguments = [named: Object, scriptName: string] // 使用sandboxContext时,arguments[0]为undefined, this.$则为一次性Proxy变量,用于全域拦截context // 非沙盒环境时,先读取 arguments[0],因此不会读取页面环境的 this.$ - // 在UserScripts API中,由于执行不是在物件导向里呼叫,使用arrow function的话会把this改变。须使用 .call(this) [ 或 .bind(this)() ] + // 临时方法调用保留 userscript 的 this,避免在页面解析可变的 call/apply/bind。 if (resource.isContextMenu) { // 脚本体整体延后到菜单回调里执行,它自己的 GM_registerMenuCommand 也随之推迟到点击后才注册 @@ -151,9 +154,9 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) const joinedCode = [ "with(arguments[0]||this.$){", `${preCode}`, - "return(async function(){", + "this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){", `${code}`, - "}).call(this);}", + "};return this[arguments[0]](...((delete this[arguments[0]]),[]));}", ] .filter(Boolean) .join("\n"); @@ -161,9 +164,27 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } +const codeFunction = (code: string) => { + // 临时方法调用不依赖页面改写的 call、apply、bind。 + return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; +}; + +const mountCodeFunction = (flag: string, code: string) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code)})`; + +const ZFunction = Function; + // 通过脚本代码编译脚本函数 export function compileScript(code: string): ScriptFunc { - return new Function(code); + const fn = new ZFunction(code); + const k = lnStrIntegrity; + const y = `${znRand}` + Math.random(); + return (t: any, u: any, ...args: any[]) => { + if (t === k) { + u[y] = fn; + return u[y](...(delete u[y], args)); + } + }; } /** @@ -186,7 +207,7 @@ export function compileInjectScriptByFlag( autoDeleteMountFunction: boolean = false ): string { const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`; + return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)};`; } /** @@ -259,7 +280,7 @@ export function compilePreInjectScript( const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}; + return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; { let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, @@ -344,16 +365,30 @@ export const getScriptFlag = (uuid: string) => { // 监听属性设置 export function definePropertyListener(obj: any, prop: string, listener: (val: T) => void) { - if (obj[prop] !== undefined) { - listener(obj[prop]); - delete obj[prop]; + const sameProperty = (left: PropertyDescriptor | undefined, right: PropertyDescriptor | undefined) => + left?.configurable === right?.configurable && + left?.enumerable === right?.enumerable && + left?.value === right?.value && + left?.get === right?.get && + left?.set === right?.set; + const current = obj[prop]; + if (current !== undefined) { + const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + listener(current); + if (sameProperty(descriptor, Object.getOwnPropertyDescriptor(obj, prop)) && descriptor?.configurable) { + delete obj[prop]; + } return; } + const setter = (val: T) => { + listener(val); + const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + if (descriptor?.configurable && descriptor.set === setter) { + delete obj[prop]; + } + }; Object.defineProperty(obj, prop, { configurable: true, - set: (val: any) => { - delete obj[prop]; // 删除 property setter - listener(val); - }, + set: setter, }); } diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 57d2df7e6..f856b1954 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -358,9 +358,9 @@ describe.concurrent("compileInjectionCode", () => { // 包含沙箱封装 expect(result).toContain("with(arguments[0]||this.$)"); - expect(result).toContain("return(async function(){"); - // 使用 compileInjectScript 包裹(window[flag] = function(){...}) - expect(result).toContain("window['#-test-uuid']"); + expect(result).toContain("this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){"); + // 使用 compileInjectScript 包裹并挂载脚本标志 + expect(result).toContain("window, '#-test-uuid'"); }); it.concurrent("预注入脚本在派发事件前执行精确 URL 规则", () => { diff --git a/vitest.config.ts b/vitest.config.ts index cb248d264..0ef021b56 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -45,6 +45,8 @@ const sharedTest = { env: { VI_TESTING: "true", SC_RANDOM_KEY: "005a7deb-3a6e-4337-83ea-b9626c02ea38", + SC_RANDOM_FNKEY: "843078d2-403b-4ec0-a6e0-358488e135ec", + SC_ZN_RAND: "4622da29-026c-47d1-a8f8-ee52bad37129", }, }; From 1eb1839c6033f6f70d11a69b89400bec453bfabf Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:32:54 +0900 Subject: [PATCH 004/106] =?UTF-8?q?=F0=9F=94=92=20isolate=20USER=5FSCRIPT?= =?UTF-8?q?=20GM=20transport=20and=20binding=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 2 +- docs/references/architecture-execution.md | 8 +- docs/references/architecture-gm-api.md | 7 +- src/app/service/content/gm_api/gm_api.test.ts | 3 +- src/app/service/content/gm_api/gm_api.ts | 80 ++++++++++++------- src/app/service/content/gm_api/gm_xhr.test.ts | 73 +++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 57 ++++++++----- src/app/service/content/page_rpc.test.ts | 46 +++++------ src/app/service/content/page_rpc.ts | 53 +++++++----- src/app/service/content/script_runtime.ts | 37 ++++++++- src/app/service/content/scripting.ts | 24 +++--- src/app/service/content/utils.test.ts | 13 +++ src/app/service/content/utils.ts | 42 +++++++++- src/app/service/service_worker/client.ts | 4 +- .../service/service_worker/runtime.test.ts | 57 +++++++++++++ src/app/service/service_worker/runtime.ts | 76 ++++++++++++++++-- src/content.ts | 27 ++++++- 17 files changed, 474 insertions(+), 135 deletions(-) create mode 100644 src/app/service/content/gm_api/gm_xhr.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 06436d77f..bc812f73f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ Each context is a separate bundle (see [Build pipeline & manifest](./references/ | Context | Entry | Realm / capabilities | Bootstraps | |---|---|---|---| | **Service Worker** | [`src/service_worker.ts`](../src/service_worker.ts) | No DOM. Owns `chrome.*` privileged APIs, storage, permissions, routing. | `ExtensionMessage(true)` → `Server("serviceWorker")` + `MessageQueue` → `ServiceWorkerManager` | -| **Content** | [`src/content.ts`](../src/content.ts) | Isolated content-script world. Bridges SW and the page. | `CustomEventMessage` channel to inject + `Server("content")` → `ScriptRuntime` | +| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles | | **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `CustomEventMessage` to content + `Server("inject")` | | **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | `ExtensionMessage()` + `WindowMessage(window, sandbox)` → `OffscreenManager` | | **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen. Evaluates background/scheduled scripts; runs cron. | `WindowMessage(window, parent)` + `Server("sandbox")` → `SandboxManager` | diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index 717ea0f12..782c08492 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -24,8 +24,8 @@ go through a controlled context object instead of the page's real globals: Key points: - `with(arguments[0]||this.$)` makes every bare identifier resolve against the GM context first. The context is - a `Proxy` that intercepts reads, so the script sees `unsafeWindow`, the granted `GM_*` functions, and a - controlled view of globals — not the raw page scope. + a descriptor-based pseudo-window that projects `unsafeWindow`, the granted `GM_*` functions, and a controlled + view of globals — not the raw page scope. It is a compatibility projection rather than a security membrane. - Context and script name are passed as **unnamed `arguments`** (`arguments[0]`, `arguments[1]`) so user code can't shadow them by declaring variables of the same name. - `.call(this)` preserves `this` because `chrome.userScripts` invokes the function free-standing (an arrow @@ -38,7 +38,9 @@ patterns and registers the compiled payload (the `scripting` bundle) with `chrom `MAIN` or `USER_SCRIPT` world as required. At document time the content/inject pair ([`script_runtime.ts`](../../src/app/service/content/script_runtime.ts), [`exec_script.ts`](../../src/app/service/content/exec_script.ts)) evaluates the compiled function with the GM -context. +context. The `USER_SCRIPT` content path obtains its matched scripts directly from the service worker over +`ExtensionMessage`; the isolated `scripting` bundle keeps the page-observable event bridge for `MAIN` execution and +the synchronous DOM helper only. ### Path B — Background scripts → Offscreen → Sandbox diff --git a/docs/references/architecture-gm-api.md b/docs/references/architecture-gm-api.md index 8282aae48..1b83aad43 100644 --- a/docs/references/architecture-gm-api.md +++ b/docs/references/architecture-gm-api.md @@ -7,12 +7,15 @@ across contexts to a privileged handler, then streams the result back. The imple - **Content side** ([`src/app/service/content/gm_api/`](../../src/app/service/content/gm_api)) — what runs *near* the userscript. Synchronous-feeling APIs (`GM_getValue`, `GM_log`) and the client half of async ones - (`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the messaging plumbing. + (`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the request facade. `USER_SCRIPT` calls use + the native extension channel; the DOM helper remains a narrow synchronous `CustomEventMessage` path. - **Service-worker side** ([`src/app/service/service_worker/gm_api/`](../../src/app/service/service_worker/gm_api)) — the privileged half: permission verification, cross-origin requests, DNR rule building. - **Offscreen side** ([`src/app/service/offscreen/gm_api.ts`](../../src/app/service/offscreen/gm_api.ts)) — DOM-dependent operations for background scripts (page-context XHR, `window.open`, clipboard). -- **Values** flow through `ValueService` and are broadcast so every tab running the same script sees updates. +- **Values** flow through `ValueService`. MAIN updates use the scripting broadcast, while USER_SCRIPT updates are + delivered over the native per-document callback port so privileged packets do not cross the page-observable DOM + channel. ### Registration: the `@GMContext.API` decorator 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 8be773dad..5f0629f39 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -59,7 +59,8 @@ describe("early-start page RPC", () => { data: expect.objectContaining({ api: "GM_xmlhttpRequest", handle: "page-binding", - envTag: "it", + version: 1, + requestId: expect.any(String), }), }); }); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 29e811bd7..154a27529 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -9,7 +9,6 @@ import type { SWScriptMenuItemOption, TScriptMenuItemID, TScriptMenuItemKey, - MessageRequest, } from "@App/app/service/service_worker/types"; import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; import { uuidv4 } from "@App/pkg/utils/uuid"; @@ -151,22 +150,33 @@ class GM_Base implements IGM_Base { if (this.loadScriptPromise) { await this.loadScriptPromise; } + // USER_SCRIPT has DOM and fetch access in its own realm. Keep these helper + // operations local instead of sending an internal CAT operation to the SW, + // where only the isolated scripting broker has an implementation. + if (this.scriptRes.executionEnvTag === ScriptEnvTag.content) { + if (api === "CAT_fetchBlob") return fetch(`${params[0]}`).then((response) => response.blob()); + if (api === "CAT_createBlobUrl") { + if (typeof URL.createObjectURL !== "function") throw new Error("Blob URLs are unavailable in USER_SCRIPT"); + return URL.createObjectURL(params[0] as Blob); + } + } let ret; try { - const request = { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - ...(this.scriptRes.executionHandle && this.scriptRes.executionEnvTag - ? { - version: 1 as const, - requestId: uuidv4(), - handle: this.scriptRes.executionHandle, - envTag: this.scriptRes.executionEnvTag, - } - : {}), - } as MessageRequest; + const request = this.scriptRes.executionHandle + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + ...(this.scriptRes.executionEnvTag === "ct" ? { executionHandle: this.scriptRes.executionHandle } : {}), + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, request); } catch (e: any) { if (`${e?.message || e}`.includes("Extension context invalidated.")) { @@ -187,20 +197,21 @@ class GM_Base implements IGM_Base { await this.loadScriptPromise; } if (!this.message || !this.scriptRes) return new Promise(() => {}); - const request = { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - ...(this.scriptRes.executionHandle && this.scriptRes.executionEnvTag - ? { - version: 1 as const, - requestId: uuidv4(), - handle: this.scriptRes.executionHandle, - envTag: this.scriptRes.executionEnvTag, - } - : {}), - } as MessageRequest; + const request = this.scriptRes.executionHandle + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + ...(this.scriptRes.executionEnvTag === "ct" ? { executionHandle: this.scriptRes.executionHandle } : {}), + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; return connect(this.message, `${this.prefix}/runtime/gmApi`, request); } @@ -577,6 +588,17 @@ export default class GMApi extends GM_Base { // 上下文已失效时直接返回,避免访问已释放的 message 造成异常 if (this.isInvalidContext()) return undefined; + if (this.scriptRes?.executionEnvTag === ScriptEnvTag.content) { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = "document"; + xhr.open("GET", url); + xhr.onloadend = () => resolve((xhr.response as Document | null) || undefined); + xhr.onerror = () => resolve(undefined); + xhr.send(); + }); + } + const message = this.message as CustomEventMessage | null; const isContentEnv = !!message && message.envTag === ScriptEnvTag.content; return urlToDocumentInContentPage(this, url, isContentEnv); diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts new file mode 100644 index 000000000..cf75a3a61 --- /dev/null +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { GM_xmlhttpRequest } from "./gm_xhr"; + +initTestEnv(); + +describe("GM_xmlhttpRequest callback cleanup", () => { + it("settles and disconnects when an error callback throws", async () => { + let onMessage!: (message: any) => void; + const connection = { + onMessage: vi.fn((callback: (message: any) => void) => { + onMessage = callback; + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror: () => { + throw new Error("user callback failed"); + }, + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(onMessage).toBeTypeOf("function")); + onMessage({ + code: 0, + action: "onerror", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onerror", + ok: false, + contentType: "text/plain", + error: "network", + }, + }); + onMessage({ + code: 0, + action: "onloadend", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onloadend", + ok: false, + contentType: "text/plain", + }, + }); + + await expect(request.retPromise).rejects.toBe("network"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onloadend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 598406983..e65152ecb 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -6,6 +6,7 @@ import type { MessageConnect, TMessage } from "@Packages/message/types"; import { base64ToUint8, concatUint8 } from "@App/pkg/utils/datatype"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; const ChunkResponseCode = { NONE: 0, @@ -499,6 +500,16 @@ export function GM_xmlhttpRequest( return makeResponseRet(retParam, addGetters, res.contentType); }; + const invokeCallback = (name: string, callback: ((value: any) => void) | undefined, value: any) => { + if (!callback) return; + try { + callback(value); + } catch (error) { + // User callback failures are reported without rejecting the internal + // message queue or interrupting request settlement. + LoggerCore.logger().error("GM_xmlhttpRequest callback failed", { name, ...Logger.E(error) }); + } + }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; let loadendCalled = false; const doLoadEnd = (data: TXhrCallBackArg) => { @@ -515,18 +526,29 @@ export function GM_xmlhttpRequest( retPromiseReject?.(errorOccur); } refCleanup?.(); - details.onloadend?.(xhrResponse); + invokeCallback("onloadend", details.onloadend, xhrResponse); } }; + const scheduleSyntheticLoadEnd = () => { + Promise.resolve({ + error: "loadend", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg).then(doLoadEnd); + }; doAbort = (data: TXhrCallBackArg) => { if (!reqDone) { errorOccur = "AbortError"; - details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + // Mark the request settled before user code runs. A throwing abort + // callback must not leave the broker connection and loadend cleanup pending. + invokeCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // doAbort 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 Abort 流程 - Promise.resolve({ ...data, type: "loadend" }).then(doLoadEnd); + scheduleSyntheticLoadEnd(); } doAbort = null; }; @@ -557,22 +579,16 @@ export function GM_xmlhttpRequest( }); if (!reqDone) { errorOccur = message; - details.onerror?.({ + reqDone = true; + invokeCallback("onerror", details.onerror, { readyState: ReadyStateCode.DONE, error: message, }); - reqDone = true; // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // 此错误多为 API 非正常执行,估计不会有 loadend 触发。见 Aborted 处理 - Promise.resolve({ - error: "loadend", - responseHeaders: "", - readyState: 0, - status: 0, - statusText: "", - } as TXhrCallBackArg).then(doLoadEnd); + scheduleSyntheticLoadEnd(); } return; } @@ -646,14 +662,14 @@ export function GM_xmlhttpRequest( break; } case "onload": - details.onload?.(makeXHRCallbackParam?.(data) ?? {}); + invokeCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { doLoadEnd(data); break; } case "onloadstart": - details.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); + invokeCallback("onloadstart", details.onloadstart, makeXHRCallbackParam?.(data) ?? {}); break; case "onprogress": { if (details.onprogress) { @@ -665,7 +681,7 @@ export function GM_xmlhttpRequest( done: data.loaded, totalSize: data.total, }; - details.onprogress?.(res); + invokeCallback("onprogress", details.onprogress, res); } break; } @@ -678,14 +694,15 @@ export function GM_xmlhttpRequest( // readable stream 的 controller 可以释放 controller = undefined; // GC用 } - details.onreadystatechange?.(makeXHRCallbackParam?.(data) ?? {}); + invokeCallback("onreadystatechange", details.onreadystatechange, makeXHRCallbackParam?.(data) ?? {}); break; } case "ontimeout": if (!reqDone) { errorOccur = "TimeoutError"; - details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + invokeCallback("ontimeout", details.ontimeout, makeXHRCallbackParam?.(data) ?? {}); + scheduleSyntheticLoadEnd(); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); } @@ -694,8 +711,12 @@ export function GM_xmlhttpRequest( if (!reqDone) { data.error ||= "Unknown Error"; errorOccur = data.error; - details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); reqDone = true; + invokeCallback( + "onerror", + details.onerror, + (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError + ); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); } diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 66586d1a2..10c78f5c8 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -40,8 +40,6 @@ describe("page GM RPC", () => { version: 1, requestId: "request-a", handle, - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [params], }, @@ -52,16 +50,19 @@ describe("page GM RPC", () => { version: 1, requestId: "request-a", handle, - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [params], + uuid: "script-a", + envTag: "it", runFlag: "canonical-run", }); expect(request.params[0]).not.toBe(params); + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-a", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("already used"); }); - it("rejects an unknown, stale, or mismatched execution binding", () => { + it("rejects an unknown or stale execution binding and supplies canonical identity", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); @@ -71,8 +72,6 @@ describe("page GM RPC", () => { version: 1, requestId: "a", handle: "missing", - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [], }, @@ -82,24 +81,22 @@ describe("page GM RPC", () => { registry.revoke(handle); expect(() => - validatePageGMRequest( - { version: 1, requestId: "b", handle, uuid: "script-a", envTag: "it", api: "GM_getValue", params: [] }, - registry - ) + validatePageGMRequest({ version: 1, requestId: "b", handle, api: "GM_getValue", params: [] }, registry) ).toThrow(PageRpcError); const activeHandle = registry.register("script-a", "it", ["GM_getValue"]); + expect( + validatePageGMRequest( + { version: 1, requestId: "c", handle: activeHandle, api: "GM_getValue", params: [] }, + registry + ) + ).toMatchObject({ + uuid: "script-a", + envTag: "it", + }); expect(() => validatePageGMRequest( - { - version: 1, - requestId: "c", - handle: activeHandle, - uuid: "script-b", - envTag: "it", - api: "GM_getValue", - params: [], - }, + { version: 1, requestId: "d", handle: activeHandle, uuid: "script-b", api: "GM_getValue", params: [] }, registry ) ).toThrow(PageRpcError); @@ -112,8 +109,6 @@ describe("page GM RPC", () => { version: 1, requestId: "a", handle, - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [], }; @@ -121,10 +116,7 @@ describe("page GM RPC", () => { expect(() => validatePageGMRequest(accessorRequest, registry)).toThrow(PageRpcError); expect(() => - validatePageGMRequest( - { version: 1, requestId: "b", handle, uuid: "script-a", envTag: "it", api: "GM_setValue", params: [] }, - registry - ) + validatePageGMRequest({ version: 1, requestId: "b", handle, api: "GM_setValue", params: [] }, registry) ).toThrow(PageRpcError); expect(() => validatePageGMRequest( @@ -132,8 +124,6 @@ describe("page GM RPC", () => { version: 1, requestId: "c", handle, - uuid: "script-a", - envTag: "it", api: "GM_getValue", params: [() => undefined], }, diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index c3c83f3ee..fe65d6dbb 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -12,17 +12,28 @@ export type PageExecutionBinding = { readonly allowedAPIs: ReadonlySet; readonly runFlag: string; active: boolean; + requestIds: Set; }; export type PageGMRequest = { readonly version: typeof PAGE_RPC_VERSION; readonly requestId: string; readonly handle: string; + readonly api: string; + readonly params: readonly unknown[]; + /** Canonical identity filled by the isolated broker after handle resolution. */ readonly uuid: string; readonly envTag: ScriptEnvTag; + readonly runFlag: string; +}; + +/** The untrusted packet accepted from a MAIN-world script. */ +export type PageGMRequestPacket = { + readonly version: typeof PAGE_RPC_VERSION; + readonly requestId: string; + readonly handle: string; readonly api: string; readonly params: readonly unknown[]; - readonly runFlag: string; }; const INTERNAL_APIS_BY_GRANT: Readonly> = { @@ -141,6 +152,7 @@ export class PageRpcRegistry { allowedAPIs: new Set(allowedAPIs), runFlag, active: true, + requestIds: new Set(), }); return handle; } @@ -154,18 +166,25 @@ export class PageRpcRegistry { for (const binding of this.bindings.values()) binding.active = false; } - resolve(handle: string, uuid: string, envTag: ScriptEnvTag, api: string): PageExecutionBinding { + resolve(handle: string, api: string): PageExecutionBinding { const binding = this.bindings.get(handle); if (!binding?.active) throw new PageRpcError("page execution binding is inactive"); - if (binding.uuid !== uuid || binding.envTag !== envTag) { - throw new PageRpcError("page execution binding does not match the request"); - } if (!binding.allowedAPIs.has(api)) throw new PageRpcError("API is not granted to this execution"); return binding; } + + consumeRequestId(binding: PageExecutionBinding, requestId: string): void { + if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); + binding.requestIds.add(requestId); + // Keep a bounded replay window for long-lived documents. + if (binding.requestIds.size > 4096) { + const oldest = binding.requestIds.values().next().value; + if (oldest) binding.requestIds.delete(oldest); + } + } } -const REQUEST_KEYS = ["version", "requestId", "handle", "uuid", "envTag", "api", "params", "runFlag"] as const; +const REQUEST_KEYS = ["version", "requestId", "handle", "api", "params"] as const; export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry): PageGMRequest => { if (value === null || typeof value !== "object") throw new PageRpcError("page RPC request must be an object"); @@ -176,9 +195,8 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) } catch { throw new PageRpcError("page RPC request cannot be inspected"); } - const hasRunFlag = keys.includes("runFlag"); if ( - keys.length !== REQUEST_KEYS.length - (hasRunFlag ? 0 : 1) || + keys.length !== REQUEST_KEYS.length || keys.some((key) => typeof key !== "string" || !REQUEST_KEYS.includes(key as never)) ) { throw new PageRpcError("page RPC request has unexpected fields"); @@ -187,31 +205,26 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) const version = ownData(value, "version"); const requestId = ownData(value, "requestId"); const handle = ownData(value, "handle"); - const uuid = ownData(value, "uuid"); - const envTag = ownData(value, "envTag"); const api = ownData(value, "api"); const params = ownData(value, "params"); - const suppliedRunFlag = keys.includes("runFlag") ? ownData(value, "runFlag") : undefined; if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); if (typeof requestId !== "string" || !requestId) throw new PageRpcError("page RPC requestId is invalid"); - if (typeof handle !== "string" || typeof uuid !== "string" || typeof envTag !== "string" || typeof api !== "string") { + if (typeof handle !== "string" || typeof api !== "string") { throw new PageRpcError("page RPC identity fields are invalid"); } - if (envTag !== "it" && envTag !== "ct") throw new PageRpcError("page RPC environment is invalid"); - if (suppliedRunFlag !== undefined && typeof suppliedRunFlag !== "string") { - throw new PageRpcError("page RPC runFlag is invalid"); - } - const binding = registry.resolve(handle, uuid, envTag, api); + const binding = registry.resolve(handle, api); + const clonedParams = cloneParams(params); + registry.consumeRequestId(binding, requestId); return { version: PAGE_RPC_VERSION, requestId, handle, - uuid, - envTag, api, - params: cloneParams(params), + params: clonedParams, + uuid: binding.uuid, + envTag: binding.envTag, runFlag: binding.runFlag, }; }; diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..46dcfaa22 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -8,6 +8,7 @@ import type { ScriptEnvTag } from "@Packages/message/consts"; import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { type TExtensionEnv } from "../extension/extension_env"; +import { RuntimeClient } from "../service_worker/client"; export class ScriptRuntime { constructor( @@ -19,11 +20,22 @@ export class ScriptRuntime { ) {} // content环境的特殊初始化 - contentInit() { - this.server.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { + contentInit(domServer: Server = this.server, domMsg: CustomEventMessage = this.msg as CustomEventMessage) { + domServer.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { + if (!data || !Array.isArray(data.params) || data.params.length !== 3) return undefined; const [parentNodeId, tagName, tmpAttr] = data.params; - const msg = this.msg as CustomEventMessage; + if ( + (parentNodeId !== null && (!Number.isInteger(parentNodeId) || parentNodeId <= 0)) || + typeof tagName !== "string" || + tagName.length === 0 || + tagName.length > 128 || + (tmpAttr !== null && (typeof tmpAttr !== "object" || Array.isArray(tmpAttr))) + ) { + return undefined; + } + + const msg = domMsg; // 取回 parentNode(如果存在) let parentNode: Node | undefined; @@ -33,7 +45,16 @@ export class ScriptRuntime { // 创建元素并设置属性 const el = document.createElement(tagName); - const attr = tmpAttr ? { ...tmpAttr } : {}; + const attr: Record = Object.create(null); + if (tmpAttr) { + for (const key of Object.keys(tmpAttr)) { + const descriptor = Object.getOwnPropertyDescriptor(tmpAttr, key); + if (!descriptor || !("value" in descriptor)) return undefined; + const value = descriptor.value; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return undefined; + attr[key] = String(value); + } + } let textContent = ""; if (attr.textContent) { textContent = attr.textContent; @@ -54,6 +75,14 @@ export class ScriptRuntime { }); } + async loadPage() { + const client = new RuntimeClient(this.msg); + const result = await client.pageLoad(this.scripEnvTag); + if (!result.ok) return; + const scripts = this.scripEnvTag === "ct" ? result.contentScriptList : result.injectScriptList; + if (scripts.length) this.startScripts(scripts, result.envInfo); + } + init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { // 转发给脚本 diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 46a3f151f..1f73d12cf 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -54,12 +54,12 @@ export default class ScriptingRuntime { init() { this.extServer.on("runtime/emitEvent", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/emitEvent", data); + // USER_SCRIPT receives private callbacks over its native extension port. + return this.broadcastToPage("runtime/emitEvent", data, PageOrContent.PAGE); }); this.extServer.on("runtime/valueUpdate", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/valueUpdate", data); + // USER_SCRIPT receives private updates over its native extension port. + return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); }); this.server.on("logger", (data: Logger) => { LoggerCore.logger().log(data.level, data.message, data.label); @@ -80,7 +80,7 @@ export default class ScriptingRuntime { const activeOn = this.activeStorageNames.get(sendData.storageName); if (activeOn) { // 转发给 content 和 inject - this.broadcastToPage("runtime/valueUpdate", sendData, activeOn); + this.broadcastToPage("runtime/valueUpdate", sendData, (activeOn & PageOrContent.PAGE) as PageOrContent); } } }); @@ -91,7 +91,7 @@ export default class ScriptingRuntime { "runtime/gmApi", this.server, this.senderToExt, - (data: { api: string; params: any; uuid: string }) => { + (data: { api: string; params: any }) => { // 拦截关注的 API,未命中则返回 false 交由默认转发处理 switch (data.api) { case "CAT_createBlobUrl": { @@ -151,6 +151,10 @@ export default class ScriptingRuntime { params: [...request.params], runFlag: request.runFlag, executionHandle: request.handle, + version: 1 as const, + requestId: request.requestId, + handle: request.handle, + envTag: request.envTag, }; } ); @@ -167,7 +171,7 @@ export default class ScriptingRuntime { }); } // 向service_worker请求脚本列表及环境信息 - client.pageLoad().then((o) => { + client.pageLoad("it").then((o) => { if (!o.ok) return; const { injectScriptList, contentScriptList, envInfo } = o; this.pageRpc.revokeAll(); @@ -195,12 +199,6 @@ export default class ScriptingRuntime { this.activeStorageNames = new Map(Object.entries(pairs)); // 向页面 发送脚本列表及环境信息 - if (preparedContentScriptList.length) { - const contentClient = new Client(this.senderToContent, "content"); - // 根据@inject-into content过滤脚本 - contentClient.do("pageLoad", { scripts: preparedContentScriptList, envInfo }); - } - if (preparedInjectScriptList.length) { const injectClient = new Client(this.senderToInject, "inject"); // 根据@inject-into content过滤脚本 diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 51ca3b255..0dd9ec2df 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -501,6 +501,19 @@ describe("utils", () => { contentType: "text/plain", }); }); + + it("copies public values and metadata before crossing the page boundary", () => { + const script = createScript({ grant: ["GM_getValue"] }, []); + script.value = { nested: { count: 1 } }; + script.metadata.grant!.push("GM_setValue"); + + const trimmed = trimScriptInfo(script); + (trimmed.value.nested as { count: number }).count = 9; + trimmed.metadata.grant!.push("GM_deleteValue"); + + expect(script.value.nested).toEqual({ count: 1 }); + expect(script.metadata.grant).toEqual(["GM_getValue", "GM_setValue"]); + }); }); describe("compileScript", () => { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index d07a2161c..3caef5952 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -8,6 +8,26 @@ import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEn import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeJSONStringify = JSON.stringify.bind(JSON); +const nativeJSONParse = JSON.parse.bind(JSON); + +const cloneTransportValue = (value: any) => { + if (value === null || typeof value !== "object") return value; + if (nativeStructuredClone) { + try { + return nativeStructuredClone(value); + } catch { + // Fall through for objects such as proxies that structuredClone rejects. + } + } + try { + return nativeJSONParse(nativeJSONStringify(value)); + } catch { + return undefined; + } +}; + const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; @@ -237,7 +257,18 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { } // --- 处理 resource --- // --- 处理 scriptInfo --- - const scriptInfo = { ...script, resource, requireCssResource, code: "" } as TScriptInfo; + const metadata = Object.fromEntries( + Object.entries(script.metadata).map(([key, values]) => [key, Array.isArray(values) ? [...values] : values]) + ); + const scriptInfo = { + ...script, + metadata, + value: cloneTransportValue(script.value) ?? {}, + config: script.config === undefined ? undefined : cloneTransportValue(script.config), + resource, + requireCssResource, + code: "", + } as TScriptInfo; // 删除其他不需要注入的 script 信息 delete scriptInfo.originalMetadata; delete scriptInfo.selfMetadata; @@ -282,9 +313,12 @@ export function compilePreInjectScript( const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; { - let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, - c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, - f = () => ${urlCondition} && performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), + let f = () => { + if (!(${urlCondition})) return false; + const o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, + c = typeof cloneInto === "function" ? cloneInto(o, performance) : o; + return performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)); + }, needWait = f(); if (needWait) performance.addEventListener('${evEnvLoad}', f, { once: true }); } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 07ea7b6fb..c70c1b806 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -339,8 +339,8 @@ export class RuntimeClient extends Client { return this.do("stopScript", uuid); } - pageLoad(): Promise { - return this.doThrow("pageLoad"); + pageLoad(envTag?: "it" | "ct"): Promise { + return this.doThrow("pageLoad", envTag ? { envTag } : undefined); } /** bfcache 还原上报:只告知本页仍在运行,不请求脚本 */ diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 51b9f948a..8b1b01436 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1162,6 +1162,63 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); }); + + it("content USER_SCRIPT 的 pageLoad 只轮换 content 绑定", async () => { + const { runtime } = _createRuntimeContext(); + const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); + const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [inject], + contentScriptList: [content], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const first = await runtime.pageLoad(undefined, sender); + expect(first.ok).toBe(true); + if (!first.ok) return; + const injectHandle = first.injectScriptList[0].executionHandle; + const contentLoad = await runtime.pageLoad({ envTag: "ct" }, sender); + + expect(contentLoad.ok).toBe(true); + if (!contentLoad.ok) return; + expect(contentLoad.injectScriptList).toEqual([]); + expect(contentLoad.contentScriptList[0].executionHandle).toEqual(expect.any(String)); + expect(runtime.resolvePageExecutionBinding(injectHandle!, sender)).toBeDefined(); + }); + + it("isolated scripting 的 pageLoad 不会撤销已建立的 content 绑定", async () => { + const { runtime } = _createRuntimeContext(); + const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); + const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [inject], + contentScriptList: [content], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const contentLoad = await runtime.pageLoad({ envTag: "ct" }, sender); + expect(contentLoad.ok).toBe(true); + if (!contentLoad.ok) return; + const contentHandle = contentLoad.contentScriptList[0].executionHandle; + const injectLoad = await runtime.pageLoad({ envTag: "it" }, sender); + + expect(injectLoad.ok).toBe(true); + expect(runtime.resolvePageExecutionBinding(contentHandle!, sender)).toBeDefined(); + }); }); describe("sandbox verified 初始化重放", () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 77228c91b..5e5d4cd11 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -6,8 +6,8 @@ import type { ServiceWorkerExecutionBinding, } from "./types"; import type { IMessageQueue } from "@Packages/message/message_queue"; -import type { Group, IGetSender } from "@Packages/message/server"; -import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; +import { GetSenderType, type Group, type IGetSender } from "@Packages/message/server"; +import type { ExtMessageSender, MessageConnect, MessageSend } from "@Packages/message/types"; import type { TClientPageLoadInfo } from "@App/app/repo/scripts"; import type { Script, ScriptDAO, ScriptRunResource, ScriptSite, TScriptInfo, UserConfig } from "@App/app/repo/scripts"; import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; @@ -142,12 +142,16 @@ export class RuntimeService { blackMatch: UrlMatch = new UrlMatch(); private gmApi?: GMApi; private readonly pageExecutionBindings = new Map(); + private readonly userScriptConnections = new Map< + string, + { connection: MessageConnect; tabId: number; frameId?: number; documentId?: string } + >(); getGMApi(): GMApi | undefined { return this.gmApi; } - private revokePageBindings(sender: IGetSender): void { + private revokePageBindings(sender: IGetSender, envTag?: "it" | "ct"): void { const source = sender.getSender(); const tabId = source?.tab?.id; const frameId = source?.frameId; @@ -156,6 +160,7 @@ export class RuntimeService { if ( binding.tabId === tabId && binding.frameId === frameId && + (envTag === undefined || binding.envTag === envTag) && (documentId === undefined || binding.documentId === documentId) ) { this.pageExecutionBindings.delete(handle); @@ -167,6 +172,54 @@ export class RuntimeService { for (const [handle, binding] of this.pageExecutionBindings) { if (binding.tabId === tabId) this.pageExecutionBindings.delete(handle); } + for (const [key, entry] of this.userScriptConnections) { + if (entry.tabId === tabId) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } + } + } + + private userScriptConnectionKey(tabId: number, frameId?: number, documentId?: string): string { + return `${tabId}:${frameId ?? -1}:${documentId ?? ""}`; + } + + /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ + registerUserScriptConnection(_: unknown, sender: IGetSender): boolean { + if (!sender.isType(GetSenderType.EXTCONNECT)) return false; + const source = sender.getSender(); + const connection = sender.getConnect(); + const tabId = source?.tab?.id; + if (!source || typeof tabId !== "number" || !connection) return false; + const frameId = source.frameId; + const documentId = source.documentId; + const key = this.userScriptConnectionKey(tabId, frameId, documentId); + const previous = this.userScriptConnections.get(key); + if (previous) previous.connection.disconnect(true); + const entry = { connection, tabId, frameId, documentId }; + this.userScriptConnections.set(key, entry); + connection.onDisconnect(() => { + if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); + }); + return true; + } + + private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { + for (const [key, entry] of this.userScriptConnections) { + if ( + to && + (entry.tabId !== to.tabId || + (to.frameId !== undefined && entry.frameId !== to.frameId) || + (to.documentId !== undefined && entry.documentId !== to.documentId)) + ) { + continue; + } + try { + entry.connection.sendMessage({ action: `content/${action}`, data }); + } catch { + this.userScriptConnections.delete(key); + } + } } private revokePageBindingsForScript(uuid: string): void { @@ -541,6 +594,9 @@ export class RuntimeService { sendData, }, }); + // USER_SCRIPT cannot observe the scripting world's page broadcast. Deliver the + // same encoded DTO over its native extension connection instead. + this.sendUserScriptMessage(undefined, "runtime/valueUpdate", sendData); // 後台腳本 if (bgScriptStorageNames.has(sendData.storageName)) { @@ -600,6 +656,7 @@ export class RuntimeService { this.group.on("runScript", this.runScript.bind(this)); this.group.on("pageLoad", this.pageLoad.bind(this)); this.group.on("pageShow", this.pageShow.bind(this)); + this.group.on("registerUserScript", this.registerUserScriptConnection.bind(this)); // 监听脚本开启 this.mq.subscribe("enableScripts", async (data) => { @@ -912,6 +969,10 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { this.pageExecutionBindings.clear(); + for (const [key, entry] of this.userScriptConnections) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } // 检查 registered 避免重复操作增加系统开支 // 已成功注册(true)或是未知有无注册(null)的情况下执行 if (runtimeGlobal.registerState !== RuntimeRegisterCode.UNREGISTER_DONE) { @@ -1228,6 +1289,7 @@ export class RuntimeService { // 如果是-1, 代表给offscreen发送消息 return sendMessage(this.msgSender, "offscreen/runtime/emitEvent", req); } + this.sendUserScriptMessage(to, "runtime/emitEvent", req); return sendMessage( new ExtensionContentMessageSend(to.tabId, { documentId: to.documentId, @@ -1334,7 +1396,7 @@ export class RuntimeService { } } - async pageLoad(_: any, sender: IGetSender): Promise { + async pageLoad(data: { envTag?: "it" | "ct" } | undefined, sender: IGetSender): Promise { const chromeSender = sender.getSender(); const url = chromeSender?.url; if (!url) { @@ -1354,7 +1416,7 @@ export class RuntimeService { }); if (res) { - this.revokePageBindings(sender); + this.revokePageBindings(sender, data?.envTag); const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => scripts.map((script) => { const binding = this.issuePageBinding(script.uuid, envTag, sender); @@ -1368,8 +1430,8 @@ export class RuntimeService { // 返回脚本资料,在页面加载 return { ok: true, - injectScriptList: prepareScripts(res.injectScriptList, "it"), - contentScriptList: prepareScripts(res.contentScriptList, "ct"), + injectScriptList: data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"), + contentScriptList: data?.envTag === "it" ? [] : prepareScripts(res.contentScriptList, "ct"), envInfo: res.envInfo, }; } else { diff --git a/src/content.ts b/src/content.ts index 094f345ac..1523872ad 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,5 +1,6 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; +import { ExtensionMessage } from "@Packages/message/extension_message"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; @@ -14,7 +15,11 @@ const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.content; - const msg: Message = new CustomEventMessage(eventFlag, false, scriptEnvTag); + // USER_SCRIPT has a native extension messaging channel. Keep the DOM channel only + // for the synchronous element helper, whose node references must remain in this realm. + const msg: Message = new ExtensionMessage(false); + const domMsg = new CustomEventMessage(eventFlag, false, scriptEnvTag); + const domContentMsg = new CustomEventMessage(eventFlag, true, scriptEnvTag); // 初始化日志组件 const logger = new LoggerCore({ @@ -26,8 +31,24 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde logger.logger().debug("content start"); const server = new Server("content", msg); - const scriptExecutor = new ScriptExecutor(msg, new CustomEventMessage(eventFlag, true, scriptEnvTag)); + const domServer = new Server("content", domMsg); + const scriptExecutor = new ScriptExecutor(msg, domContentMsg); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); - runtime.contentInit(); + runtime.contentInit(domServer, domMsg); runtime.init(); + // Keep a native port for callbacks and value updates. The page-observable event + // channel remains limited to the synchronous DOM helper. + void chrome.runtime.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" }); + void msg + .connect({ action: "serviceWorker/runtime/registerUserScript", data: { world: "USER_SCRIPT" } }) + .then((connection) => { + connection.onMessage((packet) => { + if (packet.action === "content/runtime/valueUpdate") { + scriptExecutor.valueUpdate(packet.data as any); + } else if (packet.action === "content/runtime/emitEvent") { + scriptExecutor.emitEvent(packet.data as any); + } + }); + }); + void runtime.loadPage(); }); From 3037623a47b83b99cbded17981a701978d836bd3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:34:27 +0900 Subject: [PATCH 005/106] =?UTF-8?q?=F0=9F=90=9B=20route=20USER=5FSCRIPT=20?= =?UTF-8?q?GM=20calls=20to=20service=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 22 +++++++++++++++++++ src/app/service/content/script_executor.ts | 5 +++-- src/content.ts | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 1d4b8ff97..a43855e32 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -33,6 +33,28 @@ function makeScript(overrides: Partial { + it("uses the configured transport prefix for USER_SCRIPT GM calls", () => { + const sendMessage = vi.fn().mockResolvedValue(undefined); + const script = makeScript({ metadata: { grant: ["GM_log"] } }); + const executor = new ScriptExecutor( + { sendMessage } as unknown as Message, + {} as Message, + "serviceWorker" + ); + + executor.execScriptEntry({ + scriptLoadInfo: script, + scriptFlag: script.flag, + envInfo: initEnvInfo, + scriptFunc: (_token: string, context: any) => context.GM_log("transport prefix"), + }); + + expect(sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/gmApi", + data: expect.objectContaining({ api: "GM_log" }), + }); + }); + it("does not resolve page-patchable Map methods for execution bookkeeping", () => { const originalSet = Map.prototype.set; const originalGet = Map.prototype.get; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 32e9cb7d1..216df2b06 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -37,7 +37,8 @@ export class ScriptExecutor { constructor( private msg: Message, - private contentMsg: Message // 用于 content <-> content/inject 通讯 + private contentMsg: Message, // 用于 content <-> content/inject 通讯 + private readonly envPrefix = "scripting" ) {} emitEvent(data: EmitEventRequest) { @@ -188,7 +189,7 @@ export class ScriptExecutor { const scriptLoadInfo = localizeObject(scriptEntry.scriptLoadInfo); const execScript = new ExecScript(scriptLoadInfo, { - envPrefix: "scripting", + envPrefix: this.envPrefix, message: this.msg, contentMsg: this.contentMsg, code: scriptFunc, diff --git a/src/content.ts b/src/content.ts index 1523872ad..f6e05a5c1 100644 --- a/src/content.ts +++ b/src/content.ts @@ -32,7 +32,7 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const server = new Server("content", msg); const domServer = new Server("content", domMsg); - const scriptExecutor = new ScriptExecutor(msg, domContentMsg); + const scriptExecutor = new ScriptExecutor(msg, domContentMsg, "serviceWorker"); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); runtime.contentInit(domServer, domMsg); runtime.init(); From 25714ff6edc4d5b8d6e475cf0253b70cb72f4faa Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:35:20 +0900 Subject: [PATCH 006/106] =?UTF-8?q?=F0=9F=A7=B9=20format=20USER=5FSCRIPT?= =?UTF-8?q?=20transport=20regression=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/script_executor.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a43855e32..1497ec3e4 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -36,11 +36,7 @@ describe("ScriptExecutor", () => { it("uses the configured transport prefix for USER_SCRIPT GM calls", () => { const sendMessage = vi.fn().mockResolvedValue(undefined); const script = makeScript({ metadata: { grant: ["GM_log"] } }); - const executor = new ScriptExecutor( - { sendMessage } as unknown as Message, - {} as Message, - "serviceWorker" - ); + const executor = new ScriptExecutor({ sendMessage } as unknown as Message, {} as Message, "serviceWorker"); executor.execScriptEntry({ scriptLoadInfo: script, From a46b04285ba818d80f35e1d4b04734ae36e4459f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:29:35 +0900 Subject: [PATCH 007/106] =?UTF-8?q?=F0=9F=94=92=20harden=20page=20and=20US?= =?UTF-8?q?ER=5FSCRIPT=20execution=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/references/architecture-execution.md | 4 +- packages/message/extension_message.ts | 23 +++++-- packages/message/server.ts | 9 +++ .../service/content/create_context.test.ts | 33 +++++++++ src/app/service/content/create_context.ts | 59 ++++++++-------- src/app/service/content/exec_script.ts | 7 +- src/app/service/content/global.ts | 8 +++ src/app/service/content/gm_api/gm_api.ts | 5 +- src/app/service/content/page_rpc.test.ts | 16 +++++ src/app/service/content/page_rpc.ts | 49 ++++++++++--- .../service/content/script_executor.test.ts | 17 +++++ src/app/service/content/script_executor.ts | 9 +++ src/app/service/content/script_runtime.ts | 7 +- .../content/user_script_connection.test.ts | 42 ++++++++++++ .../service/content/user_script_connection.ts | 22 ++++++ .../service_worker/gm_api/gm_api.test.ts | 45 ++++++++++++ .../service/service_worker/gm_api/gm_api.ts | 7 ++ .../service/service_worker/runtime.test.ts | 68 ++++++++++++++++++- src/app/service/service_worker/runtime.ts | 59 ++++++++++++++-- src/app/service/service_worker/types.ts | 4 ++ src/content.ts | 17 ++--- 21 files changed, 442 insertions(+), 68 deletions(-) create mode 100644 src/app/service/content/user_script_connection.test.ts create mode 100644 src/app/service/content/user_script_connection.ts diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index 782c08492..ed4d51fee 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -28,8 +28,8 @@ Key points: view of globals — not the raw page scope. It is a compatibility projection rather than a security membrane. - Context and script name are passed as **unnamed `arguments`** (`arguments[0]`, `arguments[1]`) so user code can't shadow them by declaring variables of the same name. -- `.call(this)` preserves `this` because `chrome.userScripts` invokes the function free-standing (an arrow - function would capture the wrong `this`). +- The wrapper installs the body as a temporary method and removes it in the same expression. This preserves the + userscript `this` without resolving mutable page `call`, `apply`, or `bind` properties. ### Path A — Page scripts → `chrome.userScripts` diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index e0702c98f..71869a9f6 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -50,7 +50,7 @@ export class ExtensionMessage implements Message { if (port !== null) { myPort = null; port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + callback(msg, new ExtensionMessageConnect(port, "extension")); } }; myPort.onMessage.addListener(handler); @@ -71,7 +71,7 @@ export class ExtensionMessage implements Message { if (port !== null) { myPort = null; port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + callback(msg, new ExtensionMessageConnect(port, "userScript")); } }; myPort.onMessage.addListener(handler); @@ -130,12 +130,18 @@ export class ExtensionMessage implements Message { // 监听用户脚本的消息 chrome.runtime.onUserScriptMessage.addListener((msg: TMessage, sender, sendResponse) => { const lastError = chrome.runtime.lastError; - if (typeof msg.action !== "string") return; if (lastError) { console.error("chrome.runtime.lastError in chrome.runtime.onUserScriptMessage:", lastError); // 消息API发生错误因此不继续执行 return false; } + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(true); + return false; + } + if (typeof msg.action !== "string") return; return callback(msg, sendResponse, sender); }); addUserScriptMessageListener = null; @@ -160,7 +166,10 @@ export class ExtensionMessageConnect implements MessageConnect { private con: chrome.runtime.Port | null; private isSelfDisconnected = false; - constructor(con: chrome.runtime.Port) { + constructor( + con: chrome.runtime.Port, + private readonly origin: "extension" | "userScript" = "extension" + ) { this.con = con; // 强引用 const handler = (msg: TMessage, _con: chrome.runtime.Port) => { listenerMgr.emit(`onMessage:${this.listenerId}`, msg); @@ -229,6 +238,10 @@ export class ExtensionMessageConnect implements MessageConnect { } return this.con; } + + getOrigin(): "extension" | "userScript" { + return this.origin; + } } export class ExtensionContentMessageSend implements MessageSend { @@ -269,7 +282,7 @@ export class ExtensionContentMessageSend implements MessageSend { return new Promise((resolve) => { const con = chrome.tabs.connect(this.tabId, this.options); con.postMessage(data); - resolve(new ExtensionMessageConnect(con)); + resolve(new ExtensionMessageConnect(con, "extension")); }); } } diff --git a/packages/message/server.ts b/packages/message/server.ts index 0c4682c23..fa16871f5 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -20,6 +20,7 @@ export interface IGetSender { getSender(): RuntimeMessageSender | undefined; getExtMessageSender(): ExtMessageSender; getConnect(): MessageConnect | undefined; + getConnectOrigin?(): "extension" | "userScript" | undefined; } export class SenderConnect { @@ -70,6 +71,10 @@ export class SenderConnect { getConnect(): MessageConnect { return this.sender; } + + getConnectOrigin(): "extension" | "userScript" | undefined { + return this.sender instanceof ExtensionMessageConnect ? this.sender.getOrigin() : undefined; + } } export class SenderRuntime { @@ -112,6 +117,10 @@ export class SenderRuntime { getConnect(): undefined { return undefined; } + + getConnectOrigin(): undefined { + return undefined; + } } type ApiFunction = (params: any, con: IGetSender) => Promise | any | void; diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index f5ccee32b..702cb00d9 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -338,6 +338,22 @@ describe("createContext: capability and lifecycle contract", () => { } }); + it("uses captured object operations when page code replaces assign and keys", () => { + const assign = vi.spyOn(Object, "assign").mockImplementation(() => { + throw new Error("page replacement"); + }); + const keys = vi.spyOn(Object, "keys").mockImplementation(() => { + throw new Error("page replacement"); + }); + try { + const context = createTestContext(["GM_getValue"]); + expect(context.GM_getValue("foo", "fallback")).toBe("bar"); + } finally { + assign.mockRestore(); + keys.mockRestore(); + } + }); + const resourceGrantChecks: Array<{ grant: string; read: (context: ReturnType) => unknown; @@ -487,6 +503,23 @@ describe("createContext: capability and lifecycle contract", () => { update("remote-2", "again", 8); expect(listener).toHaveBeenCalledTimes(1); }); + + it("事件回调收到独立快照,不能改写传输中的事件数据", () => { + const context = createTestContext(["CAT.agent.task"]); + let observed: { nested: { value: number } } | undefined; + const received = vi.fn((data: { nested: { value: number } }) => { + observed = { nested: { value: data.nested.value } }; + data.nested.value = 99; + }); + + context.CAT.agent.task.addListener("task-a", received); + const eventData = { nested: { value: 1 } }; + context.emitEvent("agentTask", "task-a", eventData); + + expect(received).toHaveBeenCalledTimes(1); + expect(observed).toEqual({ nested: { value: 1 } }); + expect(eventData).toEqual({ nested: { value: 1 } }); + }); }); describe.sequential("createProxyContext: module default split roots", () => { diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 0fb81cbf2..5afe54997 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -12,7 +12,7 @@ import { Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { const capability = (...args: any[]) => Native.reflectApply(api, receiver, args); - Object.defineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); + Native.objectDefineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); return capability; }; @@ -39,7 +39,7 @@ export const createContext = ( }); } let invalid = false; - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GMInfo; const context = createGMBase({ prefix: envPrefix, @@ -52,8 +52,8 @@ export const createContext = ( eventId: 10000, GM: GM, GM_info: GMInfo, - window: Object.create(null), - grantSet: new Set(), + window: Native.objectCreate(null), + grantSet: new Native.Set(), loadScriptPromise, loadScriptResolve, setInvalidContext() { @@ -72,7 +72,7 @@ export const createContext = ( return invalid; }, }); - const grantedAPIs: { [key: string]: any } = Object.create(null); + const grantedAPIs: { [key: string]: any } = Native.objectCreate(null); const __methodInject__ = (grant: string): boolean => { const grantSet: Set = context.grantSet; const s = GMContextApiGet(grant); @@ -96,7 +96,7 @@ export const createContext = ( } } // 兼容GM.Cookie.* - for (const fnKey of Object.keys(grantedAPIs)) { + for (const fnKey of Native.objectKeys(grantedAPIs)) { const fnKeyArray = fnKey.split("."); const m = fnKeyArray.length; let g = context; @@ -104,7 +104,7 @@ export const createContext = ( for (let i = 0; i < m; i++) { const part = fnKeyArray[i]; s += `${i ? "." : ""}${part}`; - g = g[part] || (g[part] = grantedAPIs[s] || Object.create(null)); + g = g[part] || (g[part] = grantedAPIs[s] || Native.objectCreate(null)); } } context.unsafeWindow = window; @@ -168,8 +168,8 @@ const getAllPropertyDescriptors = ( callback: (key: string | symbol, descriptor: PropertyDescriptor) => void ) => { while (obj && obj !== Object) { - const descs = Object.getOwnPropertyDescriptors(obj); - for (const key of Reflect.ownKeys(descs)) { + const descs = Native.objectGetOwnPropertyDescriptors(obj); + for (const key of Native.reflectOwnKeys(descs)) { callback(key, descs[key as keyof typeof descs]); } obj = Object.getPrototypeOf(obj); @@ -214,25 +214,25 @@ export type RealmRoots = { const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 // 先处理的 descriptor 覆盖后续父类。 - const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); + const descsCache: Set = new Native.Set(["eval", "window", "self", "globalThis", "top", "parent"]); // realmGlobal own descriptor 优先,hostWindow descriptor 只补足 host 成员。 - const initOwnDescs = Object.getOwnPropertyDescriptors(realmGlobal); + const initOwnDescs = Native.objectGetOwnPropertyDescriptors(realmGlobal); // overriddenDescs 将以物件 OwnPropertyDescriptor 方式进行物件属性修改。 // 覆盖原有的 OwnPropertyDescriptor 定义或父类的 PropertyDescriptor 定义。 - const overriddenDescs: DescriptorMap = Object.create(null); + const overriddenDescs: DescriptorMap = Native.objectCreate(null); // 记录原生 onxxxxx 的 property key。 - const eventKeys = new Set(); + const eventKeys = new Native.Set(); // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 - const protoBaseDescs: DescriptorMap = Object.create(null); + const protoBaseDescs: DescriptorMap = Native.objectCreate(null); const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 - const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); - for (const key of Object.keys(descriptors)) { + const descriptors = Native.objectGetOwnPropertyDescriptors(realmGlobal); + for (const key of Native.objectKeys(descriptors)) { const desc = descriptors[key]; if (descsCache.has(key)) continue; descsCache.add(key); // realm own descriptors take precedence over host descriptors @@ -278,7 +278,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); - } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { + } else if (!(key in initOwnDescs) && !Native.objectHasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } return; @@ -330,13 +330,13 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }); const sharedInitCopy = USE_PSEUDO_WINDOW - ? Object.create(null, { + ? Native.objectCreate(null, { ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype - ...Object.getOwnPropertyDescriptors(PseudoWindowPrototype), + ...Native.objectGetOwnPropertyDescriptors(PseudoWindowPrototype), ...initOwnDescs, ...overriddenDescs, }) - : Object.create(Object.getPrototypeOf(realmGlobal), { + : Native.objectCreate(Native.objectGetPrototypeOf(realmGlobal), { ...initOwnDescs, ...overriddenDescs, }); @@ -347,7 +347,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const defaultGlobalSnapshot = createGlobalSnapshot({ realmGlobal: global, hostWindow: window }); // 把沙盒的 console 和网页的 console 隔离 -const initConsoleDescs = Object.getOwnPropertyDescriptors(console); +const initConsoleDescs = Native.objectGetOwnPropertyDescriptors(console); const ConsolePrototype = Object.getPrototypeOf(console); type GMWorldContext = typeof globalThis & Record; @@ -364,7 +364,7 @@ export const createProxyContext = ( const { sharedInitCopy, eventKeys } = roots.realmGlobal === global && roots.hostWindow === window ? defaultGlobalSnapshot : createGlobalSnapshot(roots); - const ownDescs = Object.getOwnPropertyDescriptors(sharedInitCopy); + const ownDescs = Native.objectGetOwnPropertyDescriptors(sharedInitCopy); // mySandbox: ScriptCat各脚本独自使用 let mySandbox: typeof sharedInitCopy | undefined = undefined; @@ -436,7 +436,7 @@ export const createProxyContext = ( } for (const key of ["top", "parent", "frames"]) { const descriptor = ownDescs[key]; - const hostValue = Reflect.get(roots.hostWindow, key, roots.hostWindow); + const hostValue = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); if (hostValue === undefined && !descriptor) continue; ownDescs[key] = { @@ -444,7 +444,7 @@ export const createProxyContext = ( configurable: true, enumerable: descriptor?.enumerable ?? true, get() { - const value = Reflect.get(roots.hostWindow, key, roots.hostWindow); + const value = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); return value === roots.hostWindow || value === roots.realmGlobal ? mySandbox : value; }, set: undefined, @@ -477,16 +477,15 @@ export const createProxyContext = ( get() { return currentValue; }, - set(nv) { - if (typeof nv !== "function") nv = null; - currentValue = nv; + set(nv: unknown) { + currentValue = typeof nv === "function" ? (nv as (this: GlobalEventHandlers, ev: UrlChangeEvent) => any) : null; return true; }, }; } // 把初始Copy加上特殊变量后,生成一份新Copy - mySandbox = Object.create(Object.getPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & + mySandbox = Native.objectCreate(Native.objectGetPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & Record; // 处理特殊关键字,不能穿越出沙盒,也不能被外部修改 @@ -498,7 +497,7 @@ export const createProxyContext = ( // 把 GM Api (或其他全域API) 复制到 脚本window // 请手动检查避开key,防止与window的属性setter有冲突 或 属性名重复 - for (const key of Object.keys(context)) { + for (const key of Native.objectKeys(context)) { if (key in protect || key === "window") continue; mySandbox[key] = context[key]; // window以外 } @@ -526,7 +525,7 @@ export const createProxyContext = ( } // 从网页 console 隔离出来的沙盒 console - mySandbox.console = Object.create(ConsolePrototype, initConsoleDescs); + mySandbox.console = Native.objectCreate(ConsolePrototype, initConsoleDescs); return mySandbox; }; diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index febd9f01e..3213398a7 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -8,6 +8,7 @@ import type { ValueUpdateDataEncoded } from "./types"; import { evaluateGMInfo } from "./gm_api/gm_info"; import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; +import { Native } from "./global"; const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; @@ -50,7 +51,7 @@ export default class ExecScript { } else { this.scriptFunc = code; } - const grantSet = new Set(scriptRes.metadata.grant || []); + const grantSet = new Native.Set(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { grantSet.add("GM_registerMenuCommand"); grantSet.delete("none"); @@ -59,14 +60,14 @@ export default class ExecScript { // 不注入任何GM api // ScriptCat行为:GM.info 和 GM_info 同时注入 // 在不改变 Context 的情况下,以 named 传入多个全域变量 - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GM_info; this.named = { GM, GM_info }; } else { // 构建脚本GM上下文 this.sandboxContext = createContext(scriptRes, GM_info, envPrefix, message, contentMsg, grantSet); if (globalInjection) { - Object.assign(this.sandboxContext, globalInjection); + Native.objectAssign(this.sandboxContext, globalInjection); } } } diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index a431a025c..ada53358d 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -15,6 +15,8 @@ export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: nativeReflectApply(nativeFunctionBind, fn, [receiver, ...args]); export const Native = { + Set, + Map, apply: nativeApply, call: nativeCall, bind: nativeBind, @@ -25,9 +27,15 @@ export const Native = { createElement: Document.prototype.createElement, ownFragment: new DocumentFragment(), objectCreate: nativeBind(Object.create, Object), + objectAssign: nativeBind(Object.assign, Object), + objectKeys: nativeBind(Object.keys, Object), + objectHasOwn: nativeBind(Object.hasOwn, Object), + objectDefineProperty: nativeBind(Object.defineProperty, Object), objectGetOwnPropertyDescriptors: nativeBind(Object.getOwnPropertyDescriptors, Object), objectGetOwnPropertyDescriptor: nativeBind(Object.getOwnPropertyDescriptor, Object), objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), + reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), + reflectGet: nativeBind(Reflect.get, Reflect), } as const; export const customClone = (o: any) => { diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 154a27529..a9b2c4369 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -129,7 +129,7 @@ class GM_Base implements IGM_Base { constructor(options: any = null, obj: any = null) { if (obj !== integrity) throw new TypeError("Illegal invocation"); - Object.assign(this, options); + Native.objectAssign(this, options); } @GMContext.protected() @@ -254,7 +254,8 @@ class GM_Base implements IGM_Base { @GMContext.protected() emitEvent(event: string, eventId: string, data: any) { if (!this.EE) return; - this.EE.emit(`${event}:${eventId}`, data); + const callbackData = data && typeof data === "object" ? customClone(data) : data; + this.EE.emit(`${event}:${eventId}`, callbackData); } } diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 10c78f5c8..111fb85e2 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -131,4 +131,20 @@ describe("page GM RPC", () => { ) ).toThrow(PageRpcError); }); + + it("rejects malformed parameters for privileged helper operations", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["CAT_fetchBlob"]); + + expect(() => + validatePageGMRequest({ version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: [42] }, registry) + ).toThrow("CAT_fetchBlob expects a URL string"); + + expect( + validatePageGMRequest( + { version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + registry + ).params + ).toEqual(["https://example.com/file"]); + }); }); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index fe65d6dbb..a2dca1636 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -3,6 +3,7 @@ import type { ScriptEnvTag } from "@Packages/message/consts"; import { getGrantCandidates } from "./gm_api/grant"; export const PAGE_RPC_VERSION = 1 as const; +const MAX_REQUEST_ID_LENGTH = 256; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; export type PageExecutionBinding = { @@ -132,6 +133,39 @@ const cloneParams = (params: unknown): readonly unknown[] => { } }; +const validateOperationParams = (api: string, params: readonly unknown[]): void => { + switch (api) { + case "CAT_fetchBlob": + if (params.length !== 1 || typeof params[0] !== "string") { + throw new PageRpcError("CAT_fetchBlob expects a URL string"); + } + return; + case "CAT_createBlobUrl": + if (params.length !== 1 || params[0] === null || typeof params[0] !== "object") { + throw new PageRpcError("CAT_createBlobUrl expects one Blob value"); + } + return; + case "CAT_fetchDocument": + if (params.length !== 2 || typeof params[0] !== "string" || typeof params[1] !== "boolean") { + throw new PageRpcError("CAT_fetchDocument expects a URL and content flag"); + } + return; + case "CAT_agentOPFS": + if ( + params.length !== 1 || + params[0] === null || + typeof params[0] !== "object" || + Array.isArray(params[0]) || + typeof (params[0] as { action?: unknown }).action !== "string" + ) { + throw new PageRpcError("CAT_agentOPFS expects an operation object"); + } + return; + default: + return; + } +}; + export class PageRpcRegistry { private readonly bindings = new Map(); @@ -158,12 +192,11 @@ export class PageRpcRegistry { } revoke(handle: string): void { - const binding = this.bindings.get(handle); - if (binding) binding.active = false; + this.bindings.delete(handle); } revokeAll(): void { - for (const binding of this.bindings.values()) binding.active = false; + this.bindings.clear(); } resolve(handle: string, api: string): PageExecutionBinding { @@ -176,11 +209,6 @@ export class PageRpcRegistry { consumeRequestId(binding: PageExecutionBinding, requestId: string): void { if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); binding.requestIds.add(requestId); - // Keep a bounded replay window for long-lived documents. - if (binding.requestIds.size > 4096) { - const oldest = binding.requestIds.values().next().value; - if (oldest) binding.requestIds.delete(oldest); - } } } @@ -209,13 +237,16 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) const params = ownData(value, "params"); if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); - if (typeof requestId !== "string" || !requestId) throw new PageRpcError("page RPC requestId is invalid"); + if (typeof requestId !== "string" || !requestId || requestId.length > MAX_REQUEST_ID_LENGTH) { + throw new PageRpcError("page RPC requestId is invalid"); + } if (typeof handle !== "string" || typeof api !== "string") { throw new PageRpcError("page RPC identity fields are invalid"); } const binding = registry.resolve(handle, api); const clonedParams = cloneParams(params); + validateOperationParams(api, clonedParams); registry.consumeRequestId(binding, requestId); return { version: PAGE_RPC_VERSION, diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 1497ec3e4..8c1816cff 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -168,6 +168,23 @@ describe("ScriptExecutor", () => { } }); + it("rejects early metadata that retargets the flag or carries a page binding", () => { + const script = makeScript({ flag: "#-executor-test-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, { ...script, uuid: "other-script" }, initEnvInfo); + executor.execEarlyScript(script.flag, { ...script, executionHandle: "other-binding" }, initEnvInfo); + expect(genuine).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + } + }); + describe("resource execution", () => { let adoptedSheets: CSSStyleSheet[]; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 216df2b06..64aab20f6 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -170,6 +170,15 @@ export class ScriptExecutor { } execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { + const expectedUuid = flag.startsWith("#-") ? flag.slice(2) : undefined; + if ( + (expectedUuid && scriptInfo.uuid !== expectedUuid) || + scriptInfo.executionHandle !== undefined || + scriptInfo.executionEnvTag !== undefined || + scriptInfo.executionRunFlag !== undefined + ) { + return; + } const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; const descriptor = typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 46dcfaa22..87fba6f5b 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -75,12 +75,15 @@ export class ScriptRuntime { }); } - async loadPage() { + async loadPage(beforeStart?: (scripts: TScriptInfo[]) => void | Promise) { const client = new RuntimeClient(this.msg); const result = await client.pageLoad(this.scripEnvTag); if (!result.ok) return; const scripts = this.scripEnvTag === "ct" ? result.contentScriptList : result.injectScriptList; - if (scripts.length) this.startScripts(scripts, result.envInfo); + if (scripts.length) { + await beforeStart?.(scripts); + this.startScripts(scripts, result.envInfo); + } } init() { diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts new file mode 100644 index 000000000..dc78b5cb4 --- /dev/null +++ b/src/app/service/content/user_script_connection.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; +import { connectUserScriptChannel } from "./user_script_connection"; + +const makeConnection = (): MessageConnect => ({ + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), +}); + +describe("connectUserScriptChannel", () => { + it("enables the native listener before opening the USER_SCRIPT port", async () => { + const connection = makeConnection(); + const order: string[] = []; + const message = { + sendMessage: vi.fn(async (packet: TMessage) => { + order.push(`send:${(packet as { type?: string }).type}`); + return true; + }), + connect: vi.fn(async (packet: TMessage) => { + order.push(`connect:${packet.action}`); + return connection; + }), + } as unknown as Message; + + await connectUserScriptChannel(message, ["handle-a"], vi.fn()); + + expect(order).toEqual(["send:userScripts.LISTEN_CONNECTIONS", "connect:serviceWorker/runtime/registerUserScript"]); + expect(connection.onMessage).toHaveBeenCalledOnce(); + }); + + it("does not open a port when the browser cannot enable USER_SCRIPT listeners", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn(), + } as unknown as Message; + + await expect(connectUserScriptChannel(message, ["handle-a"], vi.fn())).resolves.toBeUndefined(); + expect(message.connect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts new file mode 100644 index 000000000..2933b383b --- /dev/null +++ b/src/app/service/content/user_script_connection.ts @@ -0,0 +1,22 @@ +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; + +type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => void; + +/** + * 先让 service worker 开启 USER_SCRIPT 监听,再建立连接;浏览器可能立即投递端口, + * 并发执行两步会丢失首个连接。 + */ +export async function connectUserScriptChannel( + message: Message, + executionHandles: readonly string[], + onPacket: UserScriptPacketHandler +): Promise { + const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); + if (enabled === false) return undefined; + const connection = await message.connect({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", executionHandles }, + }); + connection.onMessage((packet) => onPacket(connection, packet)); + return connection; +} diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index c2b811bfe..696f764c0 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -152,6 +152,51 @@ describe("page execution binding gate", () => { ).rejects.toThrow("page execution binding is invalid"); expect(resolveBinding).toHaveBeenCalledTimes(1); }); + + it("rejects a replayed page request id before invoking the GM API", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + Object.defineProperty(api, "parseRequest", { + configurable: true, + value: vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }), + }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + requestIds: new Set(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + const request = { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-a", + version: 1 as const, + }; + await expect(api.handlerRequest(request, sender)).resolves.toBe(true); + await expect(api.handlerRequest(request, sender)).rejects.toThrow("page RPC requestId was already used"); + }); }); describe("window.focus", () => { diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index dcf4314bc..b3654e1e9 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -395,6 +395,13 @@ export default class GMApi { if (!binding || (data.uuid && data.uuid !== binding.uuid)) { throw new Error("page execution binding is invalid"); } + if (typeof data.requestId !== "string" || !data.requestId || data.requestId.length > 256) { + throw new Error("page RPC requestId is invalid"); + } + if (binding.requestIds.has(data.requestId)) { + throw new Error("page RPC requestId was already used"); + } + binding.requestIds.add(data.requestId); if (data.envTag !== undefined && data.envTag !== binding.envTag) { throw new Error("page execution binding is invalid"); } diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 8b1b01436..18514c687 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -20,7 +20,7 @@ import type { ResourceService } from "./resource"; 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 { getStorageName, obtainBlackList } from "@App/pkg/utils/utils"; import type { CompiledResource, Resource } from "@App/app/repo/resource"; initTestEnv(); @@ -1221,6 +1221,72 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); +describe("USER_SCRIPT native callbacks", () => { + it("只向当前文档中声明了对应脚本或 storageName 的连接投递更新", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [], + contentScriptList: [script], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sendMessage = vi.fn(); + const connection = { + onMessage: vi.fn(), + sendMessage, + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const connectionSender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + + await runtime.pageLoad({ envTag: "ct" }, new SenderRuntime(rawSender)); + const contentBindings = [...(runtime as any).pageExecutionBindings.values()] as Array<{ handle: string }>; + const handles = contentBindings.map(({ handle }) => handle); + expect(handles).toHaveLength(1); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", executionHandles: handles }, + { ...connectionSender, getConnectOrigin: () => "extension" as const } + ) + ).toBe(false); + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT" }, connectionSender)).toBe(false); + expect( + runtime.registerUserScriptConnection({ world: "USER_SCRIPT", executionHandles: handles }, connectionSender) + ).toBe(true); + + const sendUserScriptMessage = (runtime as any).sendUserScriptMessage.bind(runtime); + sendUserScriptMessage(undefined, "runtime/valueUpdate", { + uuid: "other-script", + storageName: getStorageName(script), + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + + sendMessage.mockClear(); + sendUserScriptMessage(undefined, "runtime/valueUpdate", { + uuid: "content-script", + storageName: "unrelated-storage", + }); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); + describe("sandbox verified 初始化重放", () => { it("忽略 fallback 通知,并且真实握手与重复握手只初始化一次脚本和语言监听", async () => { const { runtime, mockSystemConfig, mockScriptDAO } = _createRuntimeContext(); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 5e5d4cd11..0f7cdd318 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -144,7 +144,7 @@ export class RuntimeService { private readonly pageExecutionBindings = new Map(); private readonly userScriptConnections = new Map< string, - { connection: MessageConnect; tabId: number; frameId?: number; documentId?: string } + { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } >(); getGMApi(): GMApi | undefined { @@ -185,18 +185,45 @@ export class RuntimeService { } /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ - registerUserScriptConnection(_: unknown, sender: IGetSender): boolean { - if (!sender.isType(GetSenderType.EXTCONNECT)) return false; + registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { + if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; + if (data === null || typeof data !== "object") return false; + const handshake = data as { world?: unknown; executionHandles?: unknown }; + if ( + Object.keys(data).length !== 2 || + handshake.world !== "USER_SCRIPT" || + !Array.isArray(handshake.executionHandles) || + handshake.executionHandles.length === 0 || + handshake.executionHandles.length > 256 || + handshake.executionHandles.some( + (handle) => typeof handle !== "string" || handle.length === 0 || handle.length > 256 + ) + ) { + return false; + } const source = sender.getSender(); const connection = sender.getConnect(); const tabId = source?.tab?.id; if (!source || typeof tabId !== "number" || !connection) return false; + const handles = new Set(handshake.executionHandles as string[]); + for (const handle of handles) { + const binding = this.pageExecutionBindings.get(handle); + if ( + !binding || + binding.envTag !== "ct" || + binding.tabId !== tabId || + binding.frameId !== source.frameId || + binding.documentId !== source.documentId + ) { + return false; + } + } const frameId = source.frameId; const documentId = source.documentId; const key = this.userScriptConnectionKey(tabId, frameId, documentId); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); - const entry = { connection, tabId, frameId, documentId }; + const entry = { connection, handles, tabId, frameId, documentId }; this.userScriptConnections.set(key, entry); connection.onDisconnect(() => { if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); @@ -214,6 +241,19 @@ export class RuntimeService { ) { continue; } + const bindingMatches = [...this.pageExecutionBindings.values()].some( + (binding) => + entry.handles.has(binding.handle) && + ((action === "runtime/emitEvent" && + typeof data === "object" && + data !== null && + (data as { uuid?: unknown }).uuid === binding.uuid) || + (action === "runtime/valueUpdate" && + typeof data === "object" && + data !== null && + (data as { storageName?: unknown }).storageName === binding.storageName)) + ); + if (!bindingMatches) continue; try { entry.connection.sendMessage({ action: `content/${action}`, data }); } catch { @@ -228,7 +268,12 @@ export class RuntimeService { } } - private issuePageBinding(uuid: string, envTag: "it" | "ct", sender: IGetSender): ServiceWorkerExecutionBinding { + private issuePageBinding( + uuid: string, + envTag: "it" | "ct", + storageName: string, + sender: IGetSender + ): ServiceWorkerExecutionBinding { const source = sender.getSender(); const tabId = source?.tab?.id; if (typeof tabId !== "number") throw new Error("page execution binding requires a tab"); @@ -241,6 +286,8 @@ export class RuntimeService { tabId, frameId: source?.frameId, documentId: source?.documentId, + storageName, + requestIds: new Set(), } satisfies ServiceWorkerExecutionBinding; this.pageExecutionBindings.set(handle, binding); return binding; @@ -1419,7 +1466,7 @@ export class RuntimeService { this.revokePageBindings(sender, data?.envTag); const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => scripts.map((script) => { - const binding = this.issuePageBinding(script.uuid, envTag, sender); + const binding = this.issuePageBinding(script.uuid, envTag, getStorageName(script), sender); return { ...script, executionHandle: binding.handle, diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 00d686bdd..977f6f0cc 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -63,6 +63,10 @@ export type ServiceWorkerExecutionBinding = { tabId: number; frameId?: number; documentId?: string; + /** 用于只向运行该脚本的文档投递值更新的存储命名空间。 */ + storageName: string; + /** 已接受的页面请求 ID;绑定销毁时一并释放,确保绑定存续期间拒绝重放。 */ + requestIds: Set; }; export type GMApiRequest = MessageRequest & { diff --git a/src/content.ts b/src/content.ts index f6e05a5c1..aa5249ffc 100644 --- a/src/content.ts +++ b/src/content.ts @@ -9,6 +9,7 @@ import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; +import { connectUserScriptChannel } from "./app/service/content/user_script_connection"; const messageFlag = process.env.SC_RANDOM_KEY!; @@ -38,17 +39,17 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde runtime.init(); // Keep a native port for callbacks and value updates. The page-observable event // channel remains limited to the synchronous DOM helper. - void chrome.runtime.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" }); - void msg - .connect({ action: "serviceWorker/runtime/registerUserScript", data: { world: "USER_SCRIPT" } }) - .then((connection) => { - connection.onMessage((packet) => { + void runtime.loadPage(async (scripts) => { + await connectUserScriptChannel( + msg, + scripts.map((script) => script.executionHandle).filter((handle): handle is string => Boolean(handle)), + (_connection, packet) => { if (packet.action === "content/runtime/valueUpdate") { scriptExecutor.valueUpdate(packet.data as any); } else if (packet.action === "content/runtime/emitEvent") { scriptExecutor.emitEvent(packet.data as any); } - }); - }); - void runtime.loadPage(); + } + ); + }); }); From d65bcf3e3513b57d50df6d15990a57d445aec1d9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:56:33 +0900 Subject: [PATCH 008/106] =?UTF-8?q?=F0=9F=94=92=20secure=20USER=5FSCRIPT?= =?UTF-8?q?=20bootstrap=20and=20page=20capability=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind USER_SCRIPT bootstrap data to the service-worker-issued document token, preserve sender provenance, and enforce per-execution GM capabilities across page RPC and native message paths. Harden captured collection operations and exact-match early startup handling. --- packages/message/extension_message.test.ts | 67 ++++++++++ packages/message/extension_message.ts | 118 +++++++++------- packages/message/server.test.ts | 92 ++++++++++++- packages/message/server.ts | 71 +++++++--- packages/message/types.ts | 4 +- src/app/repo/scripts.ts | 2 + .../service/content/create_context.test.ts | 43 ++++++ src/app/service/content/create_context.ts | 80 ++++++----- src/app/service/content/exec_script.ts | 8 +- src/app/service/content/global.ts | 37 ++++- src/app/service/content/gm_api/gm_api.ts | 19 +-- src/app/service/content/gm_api/gm_xhr.ts | 18 ++- src/app/service/content/page_rpc.test.ts | 105 ++++++++++++++- src/app/service/content/page_rpc.ts | 89 ++++++++++++- src/app/service/content/script_executor.ts | 9 +- src/app/service/content/scripting.test.ts | 67 ++++++++++ src/app/service/content/scripting.ts | 17 ++- .../content/user_script_connection.test.ts | 5 +- .../service/content/user_script_connection.ts | 5 +- src/app/service/content/utils.test.ts | 40 ++++++ src/app/service/content/utils.ts | 10 +- .../service_worker/gm_api/gm_api.test.ts | 41 ++++++ .../service/service_worker/gm_api/gm_api.ts | 10 ++ .../service/service_worker/runtime.test.ts | 63 +++++++-- src/app/service/service_worker/runtime.ts | 126 ++++++++++++++++-- src/app/service/service_worker/types.ts | 2 + src/content.ts | 52 ++++++-- 27 files changed, 1021 insertions(+), 179 deletions(-) create mode 100644 packages/message/extension_message.test.ts create mode 100644 src/app/service/content/scripting.test.ts diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts new file mode 100644 index 000000000..efae4b4fc --- /dev/null +++ b/packages/message/extension_message.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; + +describe("ExtensionMessage USER_SCRIPT compatibility", () => { + it("does not require unavailable runtime event listeners", () => { + const runtime = chrome.runtime as unknown as { + onConnect?: typeof chrome.runtime.onConnect; + onMessage?: typeof chrome.runtime.onMessage; + }; + const onConnect = runtime.onConnect; + const onMessage = runtime.onMessage; + + try { + runtime.onConnect = undefined; + runtime.onMessage = undefined; + const message = new ExtensionMessage(); + + expect(() => message.onConnect(() => undefined)).not.toThrow(); + expect(() => message.onMessage(() => undefined)).not.toThrow(); + } finally { + runtime.onConnect = onConnect; + runtime.onMessage = onMessage; + } + }); + + it("keeps native sendMessage and connect bindings after runtime mutation", async () => { + const runtime = chrome.runtime as unknown as { + sendMessage: unknown; + connect: unknown; + }; + const sendMessage = runtime.sendMessage; + const connect = runtime.connect; + const message = new ExtensionMessage(); + + try { + runtime.sendMessage = () => { + throw new Error("patched sendMessage"); + }; + runtime.connect = () => { + throw new Error("patched connect"); + }; + + await expect(message.sendMessage({ action: "test" })).resolves.toMatchObject({ success: true }); + await expect(message.connect({ action: "test" })).resolves.toBeDefined(); + } finally { + runtime.sendMessage = sendMessage; + runtime.connect = connect; + } + }); + + it("keeps a native port postMessage binding after port mutation", () => { + const nativePostMessage = vi.fn(); + const port = { + postMessage: nativePostMessage, + onMessage: { addListener: vi.fn(), removeListener: vi.fn() }, + onDisconnect: { addListener: vi.fn(), removeListener: vi.fn() }, + disconnect: vi.fn(), + } as unknown as chrome.runtime.Port; + const connection = new ExtensionMessageConnect(port); + + port.postMessage = vi.fn(); + connection.sendMessage({ action: "native" }); + + expect(nativePostMessage).toHaveBeenCalledWith({ action: "native" }); + connection.disconnect(true); + }); +}); diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 71869a9f6..1e519470a 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -1,24 +1,42 @@ import EventEmitter from "eventemitter3"; -import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage, TMessageCommAction } from "./types"; +import type { + Message, + MessageConnect, + MessageSend, + RuntimeMessageSender, + MessageOrigin, + TMessage, + TMessageCommAction, +} from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; const listenerMgr = new EventEmitter(); // 单一管理器 +const runtimeApi = typeof chrome === "undefined" ? undefined : chrome.runtime; +const nativeRuntimeConnect = + typeof runtimeApi?.connect === "function" ? runtimeApi.connect.bind(runtimeApi) : undefined; +const nativeRuntimeSendMessage = + typeof runtimeApi?.sendMessage === "function" ? runtimeApi.sendMessage.bind(runtimeApi) : undefined; export class ExtensionMessage implements Message { constructor(private backgroundPrimary = false) {} connect(data: TMessage): Promise { return new Promise((resolve) => { - const con = chrome.runtime.connect(); - con.postMessage(data); - resolve(new ExtensionMessageConnect(con)); + if (!nativeRuntimeConnect) throw new Error("chrome.runtime.connect is unavailable"); + const con = nativeRuntimeConnect(); + const connection = new ExtensionMessageConnect(con); + connection.sendMessage(data); + resolve(connection); }); } // 发送消息 注意不进行回调的内存泄漏 sendMessage(data: TMessage): Promise { return new Promise((resolve: ((value: T) => void) | null) => { - chrome.runtime.sendMessage(data, (resp: T) => { + if (!nativeRuntimeSendMessage) { + throw new Error("chrome.runtime.sendMessage is unavailable"); + } + nativeRuntimeSendMessage(data, (resp: T) => { const lastError = chrome.runtime.lastError; if (lastError) { console.error("chrome.runtime.lastError in chrome.runtime.sendMessage:", lastError); @@ -38,23 +56,25 @@ export class ExtensionMessage implements Message { }; onConnect(callback: (data: TMessage, con: MessageConnect) => void) { - chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { - let myPort: chrome.runtime.Port | null = port; - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.runtime.onConnect", lastError); - // 消息API发生错误因此不继续执行 - } - const handler = (msg: TMessage) => { - const port = myPort; - if (port !== null) { - myPort = null; - port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port, "extension")); + if (typeof chrome.runtime?.onConnect?.addListener === "function") { + chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { + let myPort: chrome.runtime.Port | null = port; + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.runtime.onConnect", lastError); + // 消息API发生错误因此不继续执行 } - }; - myPort.onMessage.addListener(handler); - }); + const handler = (msg: TMessage) => { + const port = myPort; + if (port !== null) { + myPort = null; + port.onMessage.removeListener(handler); + callback(msg, new ExtensionMessageConnect(port, "extension")); + } + }; + myPort.onMessage.addListener(handler); + }); + } if (this.backgroundPrimary) { let addUserScriptConnectionListener: (() => void) | null = () => { @@ -97,32 +117,35 @@ export class ExtensionMessage implements Message { callback: ( data: TMessageCommAction, sendResponse: (data: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) => boolean | void ): void { - chrome.runtime.onMessage.addListener((msg: TMessage, sender, sendResponse) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.runtime.onMessage:", lastError); - // 消息API发生错误因此不继续执行 - return false; - } - if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { - if ( - typeof chrome.runtime.onUserScriptConnect?.addListener === "function" && - typeof chrome.runtime.onUserScriptMessage?.addListener === "function" - ) { - this.tryEnableUserScriptConnectionListener(); - this.tryEnableUserScriptMessageListener(); - sendResponse(true); - } else { - sendResponse(false); + if (typeof chrome.runtime?.onMessage?.addListener === "function") { + chrome.runtime.onMessage.addListener((msg: TMessage, sender, sendResponse) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.runtime.onMessage:", lastError); + // 消息API发生错误因此不继续执行 + return false; } - return false; - } - if (typeof msg.action !== "string") return; - return callback(msg, sendResponse, sender); - }); + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + if ( + typeof chrome.runtime.onUserScriptConnect?.addListener === "function" && + typeof chrome.runtime.onUserScriptMessage?.addListener === "function" + ) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(true); + } else { + sendResponse(false); + } + return false; + } + if (typeof msg.action !== "string") return; + return callback(msg, sendResponse, sender, "extension"); + }); + } if (this.backgroundPrimary) { let addUserScriptMessageListener: (() => void) | null = () => { @@ -142,7 +165,7 @@ export class ExtensionMessage implements Message { return false; } if (typeof msg.action !== "string") return; - return callback(msg, sendResponse, sender); + return callback(msg, sendResponse, sender, "userScript"); }); addUserScriptMessageListener = null; } catch { @@ -164,6 +187,7 @@ export class ExtensionMessage implements Message { export class ExtensionMessageConnect implements MessageConnect { private readonly listenerId = `${uuidv4()}`; // 使用 uuidv4 确保唯一 private con: chrome.runtime.Port | null; + private readonly postMessage: (data: TMessage) => void; private isSelfDisconnected = false; constructor( @@ -171,6 +195,8 @@ export class ExtensionMessageConnect implements MessageConnect { private readonly origin: "extension" | "userScript" = "extension" ) { this.con = con; // 强引用 + if (typeof con.postMessage !== "function") throw new TypeError("Invalid runtime port"); + this.postMessage = con.postMessage.bind(con); const handler = (msg: TMessage, _con: chrome.runtime.Port) => { listenerMgr.emit(`onMessage:${this.listenerId}`, msg); }; @@ -197,7 +223,7 @@ export class ExtensionMessageConnect implements MessageConnect { // 無法 sendMessage 不应该屏蔽错误 throw new Error("Attempted to sendMessage on a disconnected port."); } - this.con.postMessage(data); + this.postMessage(data); } onMessage(callback: (data: TMessage) => void) { diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..3b572c0ac 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; -import { GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; +import { forwardMessage, GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; import { CustomEventMessage } from "./custom_event_message"; import type { MessageConnect, RuntimeMessageSender } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; @@ -35,6 +35,49 @@ afterEach(() => { }); describe("Server", () => { + it("应该在消息和长连接转发中都应用参数转换", async () => { + const transformed: unknown[] = []; + const targetFlag = `${uuidv4()}::target`; + const targetInbound = new CustomEventMessage(targetFlag, true); + const targetOutbound = new CustomEventMessage(targetFlag, false); + const targetServer = new Server("service", targetInbound); + targetServer.on("stream", (params) => { + transformed.push(params); + return "connected"; + }); + targetServer.on("call", (params) => { + transformed.push(params); + return "called"; + }); + + const sourceFlag = `${uuidv4()}::source`; + const sourceInbound = new CustomEventMessage(sourceFlag, true); + const sourceOutbound = new CustomEventMessage(sourceFlag, false); + const sourceServer = new Server("source", sourceInbound); + const targetSender = { + sendMessage: (data: any) => targetOutbound.sendMessage(data), + connect: (data: any) => targetOutbound.connect(data), + }; + forwardMessage("service", "stream", sourceServer, targetSender, undefined, (params) => ({ + ...params, + transformed: true, + })); + forwardMessage("service", "call", sourceServer, targetSender, undefined, (params) => ({ + ...params, + transformed: true, + })); + + const stream = await sourceOutbound.connect({ action: "source/stream", data: { value: 1 } }); + const response = await sourceOutbound.sendMessage({ action: "source/call", data: { value: 2 } }); + + expect(response.data).toBe("called"); + expect(transformed).toEqual([ + { value: 1, transformed: true }, + { value: 2, transformed: true }, + ]); + stream.disconnect(true); + }); + describe("基本功能测试 1", () => { it.concurrent("应该能够注册和调用 API", async () => { const mockHandler = vi.fn().mockResolvedValue("test response"); @@ -489,6 +532,19 @@ describe("Server", () => { expect(extSender.documentId).toBe("doc-123"); }); + it("应该把扩展消息来源传给 SenderRuntime", () => { + let capturedOrigin: string | undefined; + server.on("on-origin", (_params, sender) => { + capturedOrigin = sender.getConnectOrigin?.(); + }); + + const sendResponse = vi.fn(); + const mockSender = { tab: { id: 123 } } as RuntimeMessageSender; + (server as any).messageHandle("on-origin", {}, sendResponse, mockSender, "userScript"); + + expect(capturedOrigin).toBe("userScript"); + }); + it.concurrent("应该为没有 tab 的 sender 返回 -1 tabId", async () => { let capturedSender: IGetSender; @@ -534,6 +590,40 @@ describe("Server", () => { }); }); + describe("USER_SCRIPT action boundary", () => { + it("rejects privileged service worker actions before dispatch", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn(); + serviceWorkerServer.on("script/getAllScripts", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle("script/getAllScripts", {}, sendResponse, sender, "userScript"); + + expect(handler).not.toHaveBeenCalled(); + expect(sendResponse).toHaveBeenCalledWith({ code: -1, message: "userScript action is not allowed" }); + }); + + it("allows only the USER_SCRIPT GM API message", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn().mockReturnValue("ok"); + serviceWorkerServer.on("runtime/gmApi", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle( + "runtime/gmApi", + { api: "GM_log" }, + sendResponse, + sender, + "userScript" + ); + + expect(handler).toHaveBeenCalledWith({ api: "GM_log" }, expect.any(SenderRuntime)); + expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: "ok" }); + }); + }); + describe("Connect 功能测试", () => { it("应该能够处理连接消息", async () => { const mockHandler = vi.fn(); diff --git a/packages/message/server.ts b/packages/message/server.ts index fa16871f5..45612d1c1 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -1,4 +1,12 @@ -import type { RuntimeMessageSender, MessageConnect, ExtMessageSender, Message, TMessage, MessageSend } from "./types"; +import type { + RuntimeMessageSender, + MessageConnect, + ExtMessageSender, + Message, + MessageOrigin, + TMessage, + MessageSend, +} from "./types"; import LoggerCore from "@App/app/logger/core"; import { connect, sendMessage } from "./client"; import { ExtensionMessageConnect } from "./extension_message"; @@ -20,7 +28,7 @@ export interface IGetSender { getSender(): RuntimeMessageSender | undefined; getExtMessageSender(): ExtMessageSender; getConnect(): MessageConnect | undefined; - getConnectOrigin?(): "extension" | "userScript" | undefined; + getConnectOrigin?(): MessageOrigin | undefined; } export class SenderConnect { @@ -79,7 +87,10 @@ export class SenderConnect { export class SenderRuntime { private readonly mType; - constructor(private sender: RuntimeMessageSender) { + constructor( + private sender: RuntimeMessageSender, + private readonly origin?: MessageOrigin + ) { this.mType = GetSenderType.RUNTIME; } @@ -118,8 +129,8 @@ export class SenderRuntime { return undefined; } - getConnectOrigin(): undefined { - return undefined; + getConnectOrigin(): MessageOrigin | undefined { + return this.origin; } } @@ -146,7 +157,7 @@ export class Server { private logger = LoggerCore.getInstance().logger({ service: "messageServer" }); constructor( - prefix: string, + private readonly prefix: string, msgReceiver: Message | Message[], private enableConnect: boolean = true ) { @@ -156,8 +167,8 @@ export class Server { msg.onConnect((msg: TMessage, con: MessageConnect) => { if (typeof msg.action !== "string") return; this.logger.trace("server onConnect", { msg }); - if (msg.action?.startsWith(prefix)) { - return this.connectHandle(msg.action.slice(prefix.length + 1), msg.data, con); + if (msg.action?.startsWith(this.prefix)) { + return this.connectHandle(msg.action.slice(this.prefix.length + 1), msg.data, con); } return false; }); @@ -165,11 +176,11 @@ export class Server { } msgReceiverList.forEach((msg) => { - msg.onMessage((msg: TMessage, sendResponse, sender) => { + msg.onMessage((msg: TMessage, sendResponse, sender, origin) => { if (typeof msg.action !== "string") return; this.logger.trace("server onMessage", { msg: msg as any }); - if (msg.action?.startsWith(prefix)) { - return this.messageHandle(msg.action.slice(prefix.length + 1), msg.data, sendResponse, sender); + if (msg.action?.startsWith(this.prefix)) { + return this.messageHandle(msg.action.slice(this.prefix.length + 1), msg.data, sendResponse, sender, origin); } }); return false; @@ -185,9 +196,15 @@ export class Server { } private connectHandle(msg: string, params: any, con: MessageConnect) { + const sender = new SenderConnect(con); + if (!this.isUserScriptActionAllowed(msg, sender.getConnectOrigin(), true)) { + con.sendMessage({ code: -1, message: "userScript action is not allowed" }); + con.disconnect(true); + return true; + } const func = this.apiFunctionMap.get(msg); if (func) { - const ret = func(params, new SenderConnect(con)); + const ret = func(params, sender); if (ret) { if (ret instanceof Promise) { ret @@ -211,12 +228,18 @@ export class Server { action: string, params: any, sendResponse: (response: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) { + if (!this.isUserScriptActionAllowed(action, origin, false)) { + sendResponse({ code: -1, message: "userScript action is not allowed" }); + this.logger.warn("userScript action rejected", { action }); + return; + } const func = this.apiFunctionMap.get(action); if (func) { try { - const ret = func(params, new SenderRuntime(sender)); + const ret = func(params, new SenderRuntime(sender, origin)); if (ret instanceof Promise) { ret .then((data) => { @@ -243,6 +266,13 @@ export class Server { this.logger.error("no such api", { action: action }); } } + + private isUserScriptActionAllowed(action: string, origin: MessageOrigin | undefined, isConnect: boolean): boolean { + if (this.prefix !== "serviceWorker" || origin !== "userScript") return true; + return isConnect + ? action === "runtime/registerUserScript" || action === "runtime/gmApi" + : action === "runtime/gmApi"; + } } export class Group { @@ -323,7 +353,7 @@ export function forwardMessage( return sendMessage(senderTo, prefix + "/" + path, params); } }; - const process = (params: any, sender: IGetSender) => { + const processTransformed = (params: any, sender: IGetSender) => { if (middleware) { // 此处是为了处理CustomEventMessage的同步消息情况 const resp = middleware(params, sender) as any; @@ -340,11 +370,12 @@ export function forwardMessage( } return handler(params, sender); }; - receiverFrom.on(path, (params, sender) => { - if (!transform) return process(params, sender); + const process = (params: any, sender: IGetSender) => { + if (!transform) return processTransformed(params, sender); const transformed = transform(params, sender); return transformed instanceof Promise - ? transformed.then((data) => process(data, sender)) - : process(transformed, sender); - }); + ? transformed.then((data) => processTransformed(data, sender)) + : processTransformed(transformed, sender); + }; + receiverFrom.on(path, process); } diff --git a/packages/message/types.ts b/packages/message/types.ts index 1b323b8de..7d710f50b 100644 --- a/packages/message/types.ts +++ b/packages/message/types.ts @@ -28,12 +28,14 @@ export type TMessageCommCode = { export type TMessage = TMessagQueueUnit | TMessageCommAction | TMessageCommCode; export type RuntimeMessageSender = chrome.runtime.MessageSender; +export type MessageOrigin = "extension" | "userScript"; export type OnConnectCallback = (data: TMessage, con: MessageConnect) => void; export type OnMessageCallback = ( data: TMessage, sendResponse: (data: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) => boolean | void; export interface Message { diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index 2171c4710..285de420a 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -158,6 +158,8 @@ export type TClientPageLoadInfo = injectScriptList: TScriptInfo[]; contentScriptList: TScriptInfo[]; envInfo: GMInfoEnv; + /** One-use token that lets the USER_SCRIPT world request its private bootstrap. */ + userScriptBootstrapToken?: string; } | { ok: false }; diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 702cb00d9..c5f9f8b70 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -305,6 +305,49 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("keeps grant construction on captured Set and iterator intrinsics", () => { + const NativeSet = Set; + const nativeArrayIterator = Array.prototype[Symbol.iterator]; + const nativeSetIterator = Set.prototype[Symbol.iterator]; + const grants = new NativeSet(); + NativeSet.prototype.add.call(grants, "GM_getValue"); + const poisonedIterator = function () { + let first = true; + return { + next() { + if (!first) return { value: undefined, done: true }; + first = false; + return { value: "GM_cookie", done: false }; + }, + }; + }; + try { + Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); + Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); + (globalThis as typeof globalThis & { Set: typeof Set }).Set = class PoisonedSet { + constructor() { + throw new Error("page replaced Set"); + } + } as unknown as typeof Set; + + const context = createContext( + createScriptInfo({ grant: ["GM_getValue"] }), + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + grants + ); + + expect(context.GM_getValue).toBeTypeOf("function"); + expect(context.GM_cookie).toBeUndefined(); + } finally { + Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: nativeArrayIterator }); + Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: nativeSetIterator }); + (globalThis as typeof globalThis & { Set: typeof Set }).Set = NativeSet; + } + }); + it("uses the service-worker execution run flag for value acknowledgments", () => { const script = { ...createScriptInfo({ grant: ["GM_getValue"] }), diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 5afe54997..99db340ae 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -53,7 +53,7 @@ export const createContext = ( GM: GM, GM_info: GMInfo, window: Native.objectCreate(null), - grantSet: new Native.Set(), + grantSet: Native.createSet(), loadScriptPromise, loadScriptResolve, setInvalidContext() { @@ -77,26 +77,29 @@ export const createContext = ( const grantSet: Set = context.grantSet; const s = GMContextApiGet(grant); if (!s) return false; // @grant 的定义未实现,略过 (返回 false 表示 @grant 不存在) - if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) - grantSet.add(grant); - for (const { fnKey, api, param } of s) { + if (Native.setHas(grantSet, grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) + Native.setAdd(grantSet, grant); + for (let i = 0; i < s.length; i += 1) { + const { fnKey, api, param } = s[i]; grantedAPIs[fnKey] = createCapability(api, context); const depend = param?.depend; if (depend) { - for (const grant of depend) { - __methodInject__(grant); - } + for (let j = 0; j < depend.length; j += 1) __methodInject__(depend[j]); } } return true; }; - for (const grant of scriptGrants) { - for (const candidate of getGrantCandidates(grant)) { + Native.setForEach(scriptGrants, (grant) => { + const candidates = getGrantCandidates(String(grant)); + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]; __methodInject__(candidate); } - } + }); // 兼容GM.Cookie.* - for (const fnKey of Native.objectKeys(grantedAPIs)) { + const grantedKeys = Native.objectKeys(grantedAPIs); + for (let i = 0; i < grantedKeys.length; i += 1) { + const fnKey = grantedKeys[i]; const fnKeyArray = fnKey.split("."); const m = fnKeyArray.length; let g = context; @@ -108,7 +111,7 @@ export const createContext = ( } } context.unsafeWindow = window; - if (scriptGrants.has("window.onurlchange") && context.onurlchange === undefined) { + if (Native.setHas(scriptGrants, "window.onurlchange") && context.onurlchange === undefined) { context.onurlchange = null; attachNavigateHandler(window as any); } @@ -169,10 +172,12 @@ const getAllPropertyDescriptors = ( ) => { while (obj && obj !== Object) { const descs = Native.objectGetOwnPropertyDescriptors(obj); - for (const key of Native.reflectOwnKeys(descs)) { + const keys = Native.reflectOwnKeys(descs); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; callback(key, descs[key as keyof typeof descs]); } - obj = Object.getPrototypeOf(obj); + obj = Native.objectGetPrototypeOf(obj); } }; @@ -214,7 +219,7 @@ export type RealmRoots = { const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 // 先处理的 descriptor 覆盖后续父类。 - const descsCache: Set = new Native.Set(["eval", "window", "self", "globalThis", "top", "parent"]); + const descsCache: Set = Native.createSet(["eval", "window", "self", "globalThis", "top", "parent"]); // realmGlobal own descriptor 优先,hostWindow descriptor 只补足 host 成员。 const initOwnDescs = Native.objectGetOwnPropertyDescriptors(realmGlobal); @@ -224,7 +229,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const overriddenDescs: DescriptorMap = Native.objectCreate(null); // 记录原生 onxxxxx 的 property key。 - const eventKeys = new Native.Set(); + const eventKeys = Native.createSet(); // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 const protoBaseDescs: DescriptorMap = Native.objectCreate(null); @@ -232,10 +237,12 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 const descriptors = Native.objectGetOwnPropertyDescriptors(realmGlobal); - for (const key of Native.objectKeys(descriptors)) { + const keys = Native.objectKeys(descriptors); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; const desc = descriptors[key]; - if (descsCache.has(key)) continue; - descsCache.add(key); // realm own descriptors take precedence over host descriptors + if (Native.setHas(descsCache, key)) continue; + Native.setAdd(descsCache, key); // realm own descriptors take precedence over host descriptors if ("value" in desc) { // 替换 function 的 this 为实际的 realm global。 @@ -248,7 +255,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 // 例:(window.)onload, (window.)onerror。 - eventKeys.add(key); + Native.setAdd(eventKeys, key); continue; } if (desc.get || desc.set) { @@ -268,16 +275,16 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 // 例:(window.)onload, (window.)onerror。 - eventKeys.add(key); + Native.setAdd(eventKeys, key); return; } - if (descsCache.has(key)) return; + if (Native.setHas(descsCache, key)) return; if ("value" in desc) { // 替换 function 的 this 为实际的 host window。 if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); - descsCache.add(key); + Native.setAdd(descsCache, key); } else if (!(key in initOwnDescs) && !Native.objectHasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } @@ -287,7 +294,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // 替换 getter setter 的 this 为实际的 host window。 // 例:(window.)location, (window.)document。 overriddenDescs[key] = materializeDescriptor(desc, hostWindow); - descsCache.add(key); + Native.setAdd(descsCache, key); } }); }; @@ -296,7 +303,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn collectRealmDescriptors(); // 第二趟 hostWindow:补齐 Firefox split-realm 的 host 成员。 collectHostWindowDescriptors(); - descsCache.clear(); // 内存释放 + Native.setClear(descsCache); // 内存释放 // sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor // OwnPropertyDescriptor定义 为 原OwnPropertyDescriptor定义 (DragEvent, MouseEvent, RegExp, EventTarget, JSON等) @@ -416,7 +423,12 @@ export const createProxyContext = ( }; }; - for (const key of eventKeys) { + const eventKeyList: string[] = []; + Native.setForEach(eventKeys, (key) => { + eventKeyList[eventKeyList.length] = String(key); + }); + for (let i = 0; i < eventKeyList.length; i += 1) { + const key = eventKeyList[i]; const eventSetterGetter = createEventProp(key); ownDescs[key] = { ...ownDescs[key], @@ -425,7 +437,9 @@ export const createProxyContext = ( } // split realm 下 hostWindow 可能经由 realmGlobal.window 暴露;这些别名必须始终留在当前 sandbox 内。 - for (const key of ["window", "self", "globalThis"]) { + const sandboxAliases = ["window", "self", "globalThis"]; + for (let i = 0; i < sandboxAliases.length; i += 1) { + const key = sandboxAliases[i]; ownDescs[key] = { configurable: true, enumerable: true, @@ -434,7 +448,9 @@ export const createProxyContext = ( }, }; } - for (const key of ["top", "parent", "frames"]) { + const windowAliases = ["top", "parent", "frames"]; + for (let i = 0; i < windowAliases.length; i += 1) { + const key = windowAliases[i]; const descriptor = ownDescs[key]; const hostValue = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); if (hostValue === undefined && !descriptor) continue; @@ -489,7 +505,9 @@ export const createProxyContext = ( Record; // 处理特殊关键字,不能穿越出沙盒,也不能被外部修改 - for (const key of ["define", "module", "exports"]) { + const moduleKeys = ["define", "module", "exports"]; + for (let i = 0; i < moduleKeys.length; i += 1) { + const key = moduleKeys[i]; mySandbox[key] = undefined; } @@ -497,7 +515,9 @@ export const createProxyContext = ( // 把 GM Api (或其他全域API) 复制到 脚本window // 请手动检查避开key,防止与window的属性setter有冲突 或 属性名重复 - for (const key of Native.objectKeys(context)) { + const contextKeys = Native.objectKeys(context); + for (let i = 0; i < contextKeys.length; i += 1) { + const key = contextKeys[i]; if (key in protect || key === "window") continue; mySandbox[key] = context[key]; // window以外 } diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 3213398a7..34fd9b75d 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -51,12 +51,12 @@ export default class ExecScript { } else { this.scriptFunc = code; } - const grantSet = new Native.Set(scriptRes.metadata.grant || []); + const grantSet = Native.createSet(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { - grantSet.add("GM_registerMenuCommand"); - grantSet.delete("none"); + Native.setAdd(grantSet, "GM_registerMenuCommand"); + Native.setDelete(grantSet, "none"); } - if (grantSet.has("none")) { + if (Native.setHas(grantSet, "none")) { // 不注入任何GM api // ScriptCat行为:GM.info 和 GM_info 同时注入 // 在不改变 Context 的情况下,以 named 传入多个全域变量 diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index ada53358d..53896b47f 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -6,6 +6,16 @@ const unsupportedAPI = () => { // 在页面或用户脚本替换调用内建函数前完成捕获。 export const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; +const nativeSetConstructor = Set; +const nativeSetAdd = Set.prototype.add; +const nativeSetHas = Set.prototype.has; +const nativeSetDelete = Set.prototype.delete; +const nativeSetClear = Set.prototype.clear; +const nativeSetForEach = Set.prototype.forEach; +const nativeMapConstructor = Map; +const nativeWeakMapConstructor = WeakMap; +const nativeWeakMapGet = WeakMap.prototype.get; +const nativeWeakMapSet = WeakMap.prototype.set; export const nativeApply = (fn: (...args: any[]) => any, receiver: any, args: any[]) => nativeReflectApply(fn, receiver, args); @@ -14,9 +24,20 @@ export const nativeCall = (fn: (...args: any[]) => any, receiver: any, ...args: export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => nativeReflectApply(nativeFunctionBind, fn, [receiver, ...args]); +const createNativeSet = (values?: readonly T[]): Set => { + const set = new nativeSetConstructor(); + if (values) { + for (let i = 0; i < values.length; i += 1) nativeReflectApply(nativeSetAdd, set, [values[i]]); + } + return set; +}; + +const createNativeWeakMap = (): WeakMap => new nativeWeakMapConstructor(); + export const Native = { - Set, - Map, + Set: nativeSetConstructor, + Map: nativeMapConstructor, + WeakMap: nativeWeakMapConstructor, apply: nativeApply, call: nativeCall, bind: nativeBind, @@ -36,6 +57,18 @@ export const Native = { objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), reflectGet: nativeBind(Reflect.get, Reflect), + setAdd: (set: Set, value: unknown) => nativeReflectApply(nativeSetAdd, set, [value]), + setHas: (set: Set, value: unknown) => nativeReflectApply(nativeSetHas, set, [value]), + setDelete: (set: Set, value: unknown) => nativeReflectApply(nativeSetDelete, set, [value]), + setClear: (set: Set) => nativeReflectApply(nativeSetClear, set, []), + setForEach: (set: Set, callback: (value: unknown, value2: unknown, set: Set) => void) => + nativeReflectApply(nativeSetForEach, set, [callback]), + createSet: createNativeSet, + weakMapGet: (map: WeakMap, key: K) => + nativeReflectApply(nativeWeakMapGet, map, [key]) as V | undefined, + weakMapSet: (map: WeakMap, key: K, value: V) => + nativeReflectApply(nativeWeakMapSet, map, [key, value]) as WeakMap, + createWeakMap: createNativeWeakMap, } as const; export const customClone = (o: any) => { diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index a9b2c4369..b46488713 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -19,6 +19,7 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; import { ScriptEnvTag } from "@Packages/message/consts"; +import { isExtensionBlobUrl } from "../page_rpc"; import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; @@ -61,16 +62,13 @@ let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; const valueChangePromiseMap: Record void> = Object.create(null); -const notificationTagMaps = new WeakMap>(); -const nativeReflectApply = Reflect.apply; -const weakMapGet = WeakMap.prototype.get; -const weakMapSet = WeakMap.prototype.set; +const notificationTagMaps = Native.createWeakMap>(); const getNotificationTagMap = (owner: object): Map => { - let map = nativeReflectApply(weakMapGet, notificationTagMaps, [owner]); + let map = Native.weakMapGet(notificationTagMaps, owner); if (!map) { - map = new Map(); - nativeReflectApply(weakMapSet, notificationTagMaps, [owner, map]); + map = new Native.Map(); + Native.weakMapSet(notificationTagMaps, owner, map); } return map; }; @@ -78,7 +76,7 @@ const getNotificationTagMap = (owner: object): Map => { const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 - execEnv.menuKeyRegistered = new Set(); + execEnv.menuKeyRegistered = Native.createSet(); execEnv.menuIdCounter = 0; execEnv.regMenuCounter = 0; } @@ -154,7 +152,10 @@ class GM_Base implements IGM_Base { // operations local instead of sending an internal CAT operation to the SW, // where only the isolated scripting broker has an implementation. if (this.scriptRes.executionEnvTag === ScriptEnvTag.content) { - if (api === "CAT_fetchBlob") return fetch(`${params[0]}`).then((response) => response.blob()); + if (api === "CAT_fetchBlob") { + if (!isExtensionBlobUrl(params[0])) throw new Error("CAT_fetchBlob expects an extension blob URL"); + return fetch(params[0]).then((response) => response.blob()); + } if (api === "CAT_createBlobUrl") { if (typeof URL.createObjectURL !== "function") throw new Error("Blob URLs are unavailable in USER_SCRIPT"); return URL.createObjectURL(params[0] as Blob); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index e65152ecb..cb3220ef4 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -127,9 +127,15 @@ const getMimeType = (contentType: string) => { return mime; }; -const docParseTypes = new Set(["application/xhtml+xml", "application/xml", "image/svg+xml", "text/html", "text/xml"]); +const docParseTypes = Native.createSet([ + "application/xhtml+xml", + "application/xml", + "image/svg+xml", + "text/html", + "text/xml", +]); -const retStateFnMap = new WeakMap, RetStateFnRecord>(); +const retStateFnMap = Native.createWeakMap(); interface RetStateFnRecord { getResponseText(): string | undefined; @@ -142,7 +148,7 @@ interface RetStateFnRecord { const xhrResponseGetters = { response: { get() { - const retTemp = retStateFnMap.get(this); + const retTemp = Native.weakMapGet(retStateFnMap, this); return retTemp?.getResponse(); }, enumerable: false, @@ -150,7 +156,7 @@ const xhrResponseGetters = { }, responseXML: { get() { - const retTemp = retStateFnMap.get(this); + const retTemp = Native.weakMapGet(retStateFnMap, this); return retTemp?.getResponseXML(); }, enumerable: false, @@ -158,7 +164,7 @@ const xhrResponseGetters = { }, responseText: { get() { - const retTemp = retStateFnMap.get(this); + const retTemp = Native.weakMapGet(retStateFnMap, this); return retTemp?.getResponseText(); }, enumerable: false, @@ -429,7 +435,7 @@ export function GM_xmlhttpRequest( const retParamObject: GMXHRResponseType = Native.objectCreate(null, descriptors); // 外部没引用 retParamObject 时,retTemp 会被自动GC const retTemp = makeRetTemp(contentType); - retStateFnMap.set(retParamObject, retTemp); + Native.weakMapSet(retStateFnMap, retParamObject, retTemp); return retParamObject; }; diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 111fb85e2..eee9bc780 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { getPageRpcAllowedAPIs, PageRpcError, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +import { + getPageRpcAllowedAPIs, + setPageRpcExtensionOrigin, + isExtensionBlobUrl, + PageRpcError, + PageRpcRegistry, + validatePageGMRequest, +} from "./page_rpc"; describe("page GM RPC", () => { it("expands only the helper operations reachable from an explicit public grant", () => { @@ -12,9 +19,10 @@ describe("page GM RPC", () => { "CAT_fetchBlob", "GM_xmlhttpRequest", "GM.xmlhttpRequest", - "CAT_fetchDocument", ]) ); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); expect(allowed).not.toContain("CAT_agentSkills"); }); @@ -24,10 +32,59 @@ describe("page GM RPC", () => { expect(allowed).toEqual(expect.arrayContaining(["GM.openInTab", "GM_openInTab", "GM_closeInTab"])); }); + it("includes storage APIs used by delete wrappers", () => { + expect(getPageRpcAllowedAPIs(["GM_deleteValue"])).toEqual( + expect.arrayContaining(["GM_deleteValue", "GM_setValue"]) + ); + expect(getPageRpcAllowedAPIs(["GM.deleteValues"])).toEqual( + expect.arrayContaining(["GM.deleteValues", "GM_setValues"]) + ); + }); + + it("includes the nested cookie methods exposed by both cookie grant spellings", () => { + expect(getPageRpcAllowedAPIs(["GM.cookie"])).toEqual( + expect.arrayContaining(["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"]) + ); + expect(getPageRpcAllowedAPIs(["GM_cookie"])).toEqual( + expect.arrayContaining(["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"]) + ); + }); + + it("does not create a GM capability set for a none grant", () => { + expect(getPageRpcAllowedAPIs(["none", "GM_getValue", "CAT.agent.dom"])).toEqual([]); + }); + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); expect(allowed).toContain("GM_xmlhttpRequest"); + expect(allowed).not.toContain("CAT_fetchBlob"); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); + }); + + it("rejects direct internal fetch helpers from a GM XHR binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", getPageRpcAllowedAPIs(["GM_xmlhttpRequest"])); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "fetch", handle, api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + registry + ) + ).toThrow("API is not granted"); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "document", + handle, + api: "CAT_fetchDocument", + params: ["https://example.com/file", false], + }, + registry + ) + ).toThrow("API is not granted"); }); it("accepts a request for the active execution binding and clones parameters", () => { @@ -138,13 +195,51 @@ describe("page GM RPC", () => { expect(() => validatePageGMRequest({ version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: [42] }, registry) - ).toThrow("CAT_fetchBlob expects a URL string"); + ).toThrow("CAT_fetchBlob expects an extension blob URL"); + + expect(isExtensionBlobUrl("https://example.com/file")).toBe(false); + const extensionBlobUrl = `blob:${chrome.runtime.getURL("/").replace(/\/$/, "")}/internal`; + expect(isExtensionBlobUrl(extensionBlobUrl)).toBe(true); expect( validatePageGMRequest( - { version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + { version: 1, requestId: "b", handle, api: "CAT_fetchBlob", params: [extensionBlobUrl] }, registry ).params - ).toEqual(["https://example.com/file"]); + ).toEqual([extensionBlobUrl]); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + expect(isExtensionBlobUrl("blob:chrome-extension://other/internal")).toBe(false); + }); + + it("validates extension blobs in USER_SCRIPT when runtime.getURL is unavailable", () => { + const runtime = chrome.runtime as unknown as { getURL?: typeof chrome.runtime.getURL }; + const getURL = runtime.getURL; + const extensionBlobUrl = `blob:chrome-extension://${chrome.runtime.id}/internal`; + try { + runtime.getURL = undefined; + setPageRpcExtensionOrigin({ protocol: "chrome-extension:", hostname: chrome.runtime.id, port: "" }); + expect(isExtensionBlobUrl(extensionBlobUrl)).toBe(true); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + } finally { + runtime.getURL = getURL; + setPageRpcExtensionOrigin(undefined); + } + }); + + it("bounds the replay window for each execution binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + + for (let index = 0; index <= 4096; index += 1) { + validatePageGMRequest( + { version: 1, requestId: `request-${index}`, handle, api: "GM_getValue", params: [] }, + registry + ); + } + + // The oldest ID leaves the bounded replay window once newer requests arrive. + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) + ).not.toThrow(); }); }); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index a2dca1636..74337dfda 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -4,7 +4,71 @@ import { getGrantCandidates } from "./gm_api/grant"; export const PAGE_RPC_VERSION = 1 as const; const MAX_REQUEST_ID_LENGTH = 256; +const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const EXTENSION_PROTOCOLS = new Set(["chrome-extension:", "moz-extension:"]); + +export type ExtensionOrigin = Pick; + +export const getExtensionOrigin = (): ExtensionOrigin | undefined => { + if (typeof chrome === "undefined" || typeof chrome.runtime?.getURL !== "function") return undefined; + try { + const url = new URL(chrome.runtime.getURL("/")); + if (!EXTENSION_PROTOCOLS.has(url.protocol) || !url.hostname) return undefined; + return { protocol: url.protocol, hostname: url.hostname, port: url.port }; + } catch { + // Ignore malformed runtime metadata and reject the URL below. + } + return undefined; +}; + +let configuredExtensionOrigin: ExtensionOrigin | undefined; + +export const setPageRpcExtensionOrigin = (value: unknown): void => { + if (value === null || typeof value !== "object") { + configuredExtensionOrigin = undefined; + return; + } + try { + const read = (key: keyof ExtensionOrigin): unknown => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + }; + const protocol = read("protocol"); + const hostname = read("hostname"); + const port = read("port"); + if ( + (protocol !== "chrome-extension:" && protocol !== "moz-extension:") || + typeof hostname !== "string" || + hostname.length === 0 || + typeof port !== "string" + ) { + configuredExtensionOrigin = undefined; + return; + } + configuredExtensionOrigin = { protocol, hostname, port }; + } catch { + configuredExtensionOrigin = undefined; + } +}; + +export const isExtensionBlobUrl = (value: unknown): value is string => { + if (typeof value !== "string") return false; + const extensionOrigin = configuredExtensionOrigin || getExtensionOrigin(); + if (!extensionOrigin) return false; + try { + const url = new URL(value); + if (url.protocol !== "blob:") return false; + const creatorOrigin = new URL(value.slice("blob:".length)); + return ( + creatorOrigin.protocol === extensionOrigin.protocol && + creatorOrigin.hostname === extensionOrigin.hostname && + creatorOrigin.port === extensionOrigin.port + ); + } catch { + return false; + } +}; export type PageExecutionBinding = { readonly handle: string; @@ -45,17 +109,26 @@ const INTERNAL_APIS_BY_GRANT: Readonly> = { "CAT.agent.skills": ["CAT_agentSkills"], "CAT.agent.task": ["CAT_agentTask"], CAT_fileStorage: ["CAT_fetchBlob", "CAT_createBlobUrl"], - GM_xmlhttpRequest: ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], - "GM.xmlhttpRequest": ["CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], - "GM.xmlHttpRequest": ["GM_xmlhttpRequest", "CAT_createBlobUrl", "CAT_fetchBlob", "CAT_fetchDocument"], + "GM.xmlHttpRequest": ["GM_xmlhttpRequest"], }; // ScriptingRuntime does not load the GM implementation module, so mirror its small dependency graph here. const API_DEPENDENCIES: Readonly> = { "GM.getValues": ["GM_getValues"], + "GM.cookie": ["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"], + GM_cookie: ["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"], + "GM.setValue": ["GM_setValue"], + "GM.setValues": ["GM_setValues"], + "GM.listValues": ["GM_listValues"], + "GM.download": ["GM_download"], + "GM.notification": ["GM_notification"], "GM.addValueChangeListener": ["GM_addValueChangeListener"], "GM.removeValueChangeListener": ["GM_removeValueChangeListener"], "GM.log": ["GM_log"], + "GM.deleteValue": ["GM_setValue"], + GM_deleteValue: ["GM_setValue"], + "GM.deleteValues": ["GM_setValues"], + GM_deleteValues: ["GM_setValues"], "GM.registerMenuCommand": ["GM_registerMenuCommand"], CAT_registerMenuInput: ["GM_registerMenuCommand"], "GM.addStyle": ["GM_addStyle"], @@ -74,6 +147,7 @@ const API_DEPENDENCIES: Readonly> = { }; export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { + if (grants.some((grant) => grant === "none")) return []; const allowed = new Set(); const visited = new Set(); const visitGrant = (grant: string): void => { @@ -136,8 +210,8 @@ const cloneParams = (params: unknown): readonly unknown[] => { const validateOperationParams = (api: string, params: readonly unknown[]): void => { switch (api) { case "CAT_fetchBlob": - if (params.length !== 1 || typeof params[0] !== "string") { - throw new PageRpcError("CAT_fetchBlob expects a URL string"); + if (params.length !== 1 || !isExtensionBlobUrl(params[0])) { + throw new PageRpcError("CAT_fetchBlob expects an extension blob URL"); } return; case "CAT_createBlobUrl": @@ -209,6 +283,11 @@ export class PageRpcRegistry { consumeRequestId(binding: PageExecutionBinding, requestId: string): void { if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); binding.requestIds.add(requestId); + while (binding.requestIds.size > MAX_REQUEST_IDS_PER_BINDING) { + const oldest = binding.requestIds.values().next().value as string | undefined; + if (oldest === undefined) break; + binding.requestIds.delete(oldest); + } } } diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 64aab20f6..3e8d62a2c 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -74,7 +74,8 @@ export class ScriptExecutor { }); }; // 监听脚本加载 - scripts.forEach((script) => { + for (let scriptIndex = 0; scriptIndex < scripts.length; scriptIndex += 1) { + const script = scripts[scriptIndex]; const flag = script.flag; // 如果是EarlyScriptFlag,处理沙盒环境 let isEarlyScript = false; @@ -110,7 +111,7 @@ export class ScriptExecutor { }); }; listenForScript(); - }); + } } checkEarlyStartScript(scriptEnvTag: ScriptEnvTag, envInfo: GMInfoEnv) { @@ -217,7 +218,9 @@ export class ScriptExecutor { const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css if (metadata["require-css"] && resource) { - for (const val of metadata["require-css"]) { + const requireCss = metadata["require-css"]; + for (let i = 0; i < requireCss.length; i += 1) { + const val = requireCss[i]; const res = resource[val]; if (res) { addStyleSheet(res.content); diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts new file mode 100644 index 000000000..960eb6b27 --- /dev/null +++ b/src/app/service/content/scripting.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import type { MessageSend } from "@Packages/message/types"; +import type { TClientPageLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import type { Server } from "@Packages/message/server"; +import { RuntimeClient } from "../service_worker/client"; +import ScriptingRuntime from "./scripting"; + +const makeSender = () => ({ + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: undefined }), + connect: vi.fn(), +}); + +const makeScript = (uuid: string): TScriptInfo => + ({ + uuid, + metadata: { grant: ["GM_getValue"] }, + resource: {}, + value: {}, + flag: `${uuid}-flag`, + code: "", + }) as unknown as TScriptInfo; + +describe("ScriptingRuntime page bootstrap", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("requests the combined page list so USER_SCRIPT content receives its bootstrap", async () => { + const pageLoad = vi.spyOn(RuntimeClient.prototype, "pageLoad").mockResolvedValue({ + ok: true, + injectScriptList: [makeScript("inject-script")], + contentScriptList: [makeScript("content-script")], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + userScriptBootstrapToken: "bootstrap-token", + } as TClientPageLoadInfo); + const senderToExt = makeSender(); + const senderToContent = makeSender(); + const senderToInject = makeSender(); + const runtime = new ScriptingRuntime( + {} as Server, + {} as Server, + senderToExt as unknown as MessageSend, + senderToContent as any, + senderToInject as any + ); + + runtime.pageLoad(); + await Promise.resolve(); + await Promise.resolve(); + + expect(pageLoad).toHaveBeenCalledWith("it"); + expect(senderToContent.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ + bootstrapToken: "bootstrap-token", + extensionOrigin: { + protocol: "chrome-extension:", + hostname: chrome.runtime.id, + port: "", + }, + }), + }) + ); + expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); + }); +}); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 1f73d12cf..b465ac641 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -7,7 +7,7 @@ import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; import type { ValueUpdateDataEncoded } from "./types"; -import { getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; import { uuidv4 } from "@App/pkg/utils/uuid"; const PageOrContent = { @@ -173,7 +173,7 @@ export default class ScriptingRuntime { // 向service_worker请求脚本列表及环境信息 client.pageLoad("it").then((o) => { if (!o.ok) return; - const { injectScriptList, contentScriptList, envInfo } = o; + const { injectScriptList, envInfo, userScriptBootstrapToken } = o; this.pageRpc.revokeAll(); const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => scripts.map((script) => { @@ -188,16 +188,21 @@ export default class ScriptingRuntime { return { ...script, executionHandle, executionEnvTag: envTag, executionRunFlag }; }); const preparedInjectScriptList = prepareScripts(injectScriptList, "it"); - const preparedContentScriptList = prepareScripts(contentScriptList, "ct"); const pairs = {} as Record; for (const script of preparedInjectScriptList) { pairs[getStorageName(script)] |= PageOrContent.PAGE; } - for (const script of preparedContentScriptList) { - pairs[getStorageName(script)] |= PageOrContent.CONTENT; - } this.activeStorageNames = new Map(Object.entries(pairs)); + if (typeof userScriptBootstrapToken === "string" && userScriptBootstrapToken.length > 0) { + const contentClient = new Client(this.senderToContent, "content"); + contentClient.do("pageLoad", { + bootstrapToken: userScriptBootstrapToken, + envInfo, + extensionOrigin: getExtensionOrigin(), + }); + } + // 向页面 发送脚本列表及环境信息 if (preparedInjectScriptList.length) { const injectClient = new Client(this.senderToInject, "inject"); diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index dc78b5cb4..170e5fd2f 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -24,10 +24,11 @@ describe("connectUserScriptChannel", () => { }), } as unknown as Message; - await connectUserScriptChannel(message, ["handle-a"], vi.fn()); + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); expect(order).toEqual(["send:userScripts.LISTEN_CONNECTIONS", "connect:serviceWorker/runtime/registerUserScript"]); expect(connection.onMessage).toHaveBeenCalledOnce(); + expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); }); it("does not open a port when the browser cannot enable USER_SCRIPT listeners", async () => { @@ -36,7 +37,7 @@ describe("connectUserScriptChannel", () => { connect: vi.fn(), } as unknown as Message; - await expect(connectUserScriptChannel(message, ["handle-a"], vi.fn())).resolves.toBeUndefined(); + await expect(connectUserScriptChannel(message, "bootstrap-token", vi.fn())).resolves.toBeUndefined(); expect(message.connect).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index 2933b383b..c11a2cd80 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -8,15 +8,16 @@ type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => */ export async function connectUserScriptChannel( message: Message, - executionHandles: readonly string[], + bootstrapToken: string, onPacket: UserScriptPacketHandler ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); if (enabled === false) return undefined; const connection = await message.connect({ action: "serviceWorker/runtime/registerUserScript", - data: { world: "USER_SCRIPT", executionHandles }, + data: { world: "USER_SCRIPT", bootstrapToken }, }); connection.onMessage((packet) => onPacket(connection, packet)); + connection.sendMessage({ action: "userScript/bootstrap" }); return connection; } diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 0dd9ec2df..0933854e1 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -792,6 +792,46 @@ describe("utils", () => { expect(testPerformance.dispatchEvent).toHaveBeenCalledTimes(1); expect(testPerformance.addEventListener).not.toHaveBeenCalled(); }); + + it.concurrent("does not mount a regex-excluded early-start script", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-excluded-uuid", + name: "Pre Inject Excluded Script", + namespace: "pre.inject.excluded", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "pre-inject-excluded-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + scriptUrlPatterns: [ + { + ruleType: RuleType.REGEX_INCLUDE, + ruleContent: ["allowed", ""], + ruleTag: "include", + patternString: "/allowed/", + }, + ], + }; + const targetWindow: GeneratedWindow = {}; + const testPerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), targetWindow, testPerformance); + + expect(targetWindow[script.flag]).toBeUndefined(); + expect(testPerformance.dispatchEvent).not.toHaveBeenCalled(); + }); }); describe("addStyle", () => { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 3caef5952..5d9910e54 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -311,10 +311,14 @@ export function compilePreInjectScript( const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; - return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; -{ - let f = () => { + return `{ + let mounted = false, + f = () => { if (!(${urlCondition})) return false; + if (!mounted) { + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; + mounted = true; + } const o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o; return performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)); diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 696f764c0..1fd0b82db 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -153,6 +153,46 @@ describe("page execution binding gate", () => { expect(resolveBinding).toHaveBeenCalledTimes(1); }); + it("rejects a page API that is outside the binding capability set", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const parseRequest = vi.fn(); + Object.defineProperty(api, "parseRequest", { configurable: true, value: parseRequest }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_getTab"]), + requestIds: new Set(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-a", + version: 1, + }, + sender + ) + ).rejects.toThrow("API is not granted to this execution"); + expect(parseRequest).not.toHaveBeenCalled(); + expect(binding.requestIds.size).toBe(0); + }); + it("rejects a replayed page request id before invoking the GM API", async () => { const api = Object.create(GMApi.prototype) as GMApi; Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); @@ -176,6 +216,7 @@ describe("page execution binding gate", () => { runFlag: "run-a", tabId: 42, frameId: 0, + allowedAPIs: new Set(["GM_log"]), requestIds: new Set(), }; Object.defineProperty(api, "resolvePageExecutionBinding", { diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index b3654e1e9..095ab64d1 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -141,6 +141,8 @@ const cleanupOnAPIError = (requestId: string) => { headersSettled(markerID); // 处理完毕 }; +const MAX_PAGE_RPC_REQUEST_IDS = 4096; + // GMExternalDependencies接口定义 // 为了支持外部依赖注入,方便测试和扩展 interface IGMExternalDependencies { @@ -395,6 +397,9 @@ export default class GMApi { if (!binding || (data.uuid && data.uuid !== binding.uuid)) { throw new Error("page execution binding is invalid"); } + if (!binding.allowedAPIs.has(data.api)) { + throw new Error("API is not granted to this execution"); + } if (typeof data.requestId !== "string" || !data.requestId || data.requestId.length > 256) { throw new Error("page RPC requestId is invalid"); } @@ -402,6 +407,11 @@ export default class GMApi { throw new Error("page RPC requestId was already used"); } binding.requestIds.add(data.requestId); + while (binding.requestIds.size > MAX_PAGE_RPC_REQUEST_IDS) { + const oldest = binding.requestIds.values().next().value as string | undefined; + if (oldest === undefined) break; + binding.requestIds.delete(oldest); + } if (data.envTag !== undefined && data.envTag !== binding.envTag) { throw new Error("page execution binding is invalid"); } diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 18514c687..581bf8a67 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1077,6 +1077,16 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }, }); + it("拒绝 USER_SCRIPT 来源直接请求页面脚本清单", async () => { + const { runtime } = _createRuntimeContext(); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab"); + + const result = await runtime.pageLoad(undefined, new SenderRuntime(createSender(false), "userScript")); + + expect(result).toEqual({ ok: false }); + expect(getScriptsForTab).not.toHaveBeenCalled(); + }); + it.each([ ["普通", false], ["隐身", true], @@ -1193,7 +1203,7 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(runtime.resolvePageExecutionBinding(injectHandle!, sender)).toBeDefined(); }); - it("isolated scripting 的 pageLoad 不会撤销已建立的 content 绑定", async () => { + it("isolated scripting 的 pageLoad 会撤销上一轮 content 绑定", async () => { const { runtime } = _createRuntimeContext(); const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); @@ -1217,7 +1227,34 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { const injectLoad = await runtime.pageLoad({ envTag: "it" }, sender); expect(injectLoad.ok).toBe(true); - expect(runtime.resolvePageExecutionBinding(contentHandle!, sender)).toBeDefined(); + expect(runtime.resolvePageExecutionBinding(contentHandle!, sender)).toBeUndefined(); + }); + + it("没有匹配脚本时也会撤销当前页面的旧绑定", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource(_createMockScript({ uuid: "stale-script" })); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab"); + getScriptsForTab.mockResolvedValueOnce({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + getScriptsForTab.mockResolvedValueOnce(null); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const first = await runtime.pageLoad(undefined, sender); + expect(first.ok).toBe(true); + if (!first.ok) return; + const handle = first.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(handle!, sender)).toBeDefined(); + + await runtime.pageLoad(undefined, sender); + expect(runtime.resolvePageExecutionBinding(handle!, sender)).toBeUndefined(); }); }); @@ -1241,8 +1278,9 @@ describe("USER_SCRIPT native callbacks", () => { tab: { id: 41, incognito: false } as chrome.tabs.Tab, } as chrome.runtime.MessageSender; const sendMessage = vi.fn(); + const onMessage = vi.fn(); const connection = { - onMessage: vi.fn(), + onMessage, sendMessage, disconnect: vi.fn(), onDisconnect: vi.fn(), @@ -1256,22 +1294,31 @@ describe("USER_SCRIPT native callbacks", () => { getConnectOrigin: () => "userScript" as const, }; - await runtime.pageLoad({ envTag: "ct" }, new SenderRuntime(rawSender)); + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); const contentBindings = [...(runtime as any).pageExecutionBindings.values()] as Array<{ handle: string }>; const handles = contentBindings.map(({ handle }) => handle); expect(handles).toHaveLength(1); + expect(pageLoad.ok && pageLoad.userScriptBootstrapToken).toEqual(expect.any(String)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; expect( runtime.registerUserScriptConnection( - { world: "USER_SCRIPT", executionHandles: handles }, + { world: "USER_SCRIPT", bootstrapToken }, { ...connectionSender, getConnectOrigin: () => "extension" as const } ) ).toBe(false); expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT" }, connectionSender)).toBe(false); - expect( - runtime.registerUserScriptConnection({ world: "USER_SCRIPT", executionHandles: handles }, connectionSender) - ).toBe(true); + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken }, connectionSender)).toBe(true); + const bootstrapHandler = onMessage.mock.calls[0]?.[0] as ((packet: TMessage) => void) | undefined; + bootstrapHandler?.({ action: "userScript/bootstrap" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ scripts: expect.any(Array) }), + }) + ); const sendUserScriptMessage = (runtime as any).sendUserScriptMessage.bind(runtime); + sendMessage.mockClear(); sendUserScriptMessage(undefined, "runtime/valueUpdate", { uuid: "other-script", storageName: getStorageName(script), diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 0f7cdd318..3e1bc47ac 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -40,6 +40,7 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { ExtensionContentMessageSend } from "@Packages/message/extension_message"; import { sendMessage } from "@Packages/message/client"; import type { CompileScriptCodeResource } from "../content/utils"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, type ExtensionOrigin } from "../content/page_rpc"; import { compileInjectScriptByFlag, compileScriptCodeByResource, @@ -146,6 +147,17 @@ export class RuntimeService { string, { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } >(); + private readonly userScriptBootstraps = new Map< + string, + { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + tabId: number; + frameId?: number; + documentId?: string; + } + >(); getGMApi(): GMApi | undefined { return this.gmApi; @@ -160,12 +172,29 @@ export class RuntimeService { if ( binding.tabId === tabId && binding.frameId === frameId && - (envTag === undefined || binding.envTag === envTag) && + (envTag === undefined || binding.envTag === envTag || (envTag === "it" && binding.envTag === "ct")) && (documentId === undefined || binding.documentId === documentId) ) { this.pageExecutionBindings.delete(handle); } } + if (envTag === "it") { + for (const [key, entry] of this.userScriptConnections) { + if ( + entry.tabId === tabId && + entry.frameId === frameId && + (documentId === undefined || entry.documentId === documentId) + ) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } + } + } + if (envTag !== "ct") { + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.tabId === tabId && bootstrap.frameId === frameId) this.userScriptBootstraps.delete(token); + } + } } revokePageBindingsForTab(tabId: number): void { @@ -178,6 +207,9 @@ export class RuntimeService { this.userScriptConnections.delete(key); } } + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.tabId === tabId) this.userScriptBootstraps.delete(token); + } } private userScriptConnectionKey(tabId: number, frameId?: number, documentId?: string): string { @@ -188,16 +220,13 @@ export class RuntimeService { registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; if (data === null || typeof data !== "object") return false; - const handshake = data as { world?: unknown; executionHandles?: unknown }; + const handshake = data as { world?: unknown; bootstrapToken?: unknown }; if ( Object.keys(data).length !== 2 || handshake.world !== "USER_SCRIPT" || - !Array.isArray(handshake.executionHandles) || - handshake.executionHandles.length === 0 || - handshake.executionHandles.length > 256 || - handshake.executionHandles.some( - (handle) => typeof handle !== "string" || handle.length === 0 || handle.length > 256 - ) + typeof handshake.bootstrapToken !== "string" || + handshake.bootstrapToken.length === 0 || + handshake.bootstrapToken.length > 256 ) { return false; } @@ -205,8 +234,19 @@ export class RuntimeService { const connection = sender.getConnect(); const tabId = source?.tab?.id; if (!source || typeof tabId !== "number" || !connection) return false; - const handles = new Set(handshake.executionHandles as string[]); - for (const handle of handles) { + const bootstrap = this.userScriptBootstraps.get(handshake.bootstrapToken); + if ( + !bootstrap || + bootstrap.tabId !== tabId || + bootstrap.frameId !== source.frameId || + bootstrap.documentId !== source.documentId + ) { + return false; + } + const handles = new Set(); + for (const script of bootstrap.scripts) { + const handle = script.executionHandle; + if (typeof handle !== "string" || handle.length === 0 || handle.length > 256) return false; const binding = this.pageExecutionBindings.get(handle); if ( !binding || @@ -217,7 +257,10 @@ export class RuntimeService { ) { return false; } + handles.add(handle); } + if (handles.size === 0) return false; + this.userScriptBootstraps.delete(handshake.bootstrapToken); const frameId = source.frameId; const documentId = source.documentId; const key = this.userScriptConnectionKey(tabId, frameId, documentId); @@ -228,6 +271,31 @@ export class RuntimeService { connection.onDisconnect(() => { if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); }); + let bootstrapped = false; + connection.onMessage((packet) => { + if ( + bootstrapped || + packet === null || + typeof packet !== "object" || + Object.keys(packet).length !== 1 || + packet.action !== "userScript/bootstrap" + ) { + return; + } + bootstrapped = true; + try { + connection.sendMessage({ + action: "content/pageLoad", + data: { + scripts: bootstrap.scripts, + envInfo: bootstrap.envInfo, + extensionOrigin: bootstrap.extensionOrigin, + }, + }); + } catch { + this.userScriptConnections.delete(key); + } + }); return true; } @@ -266,12 +334,16 @@ export class RuntimeService { for (const [handle, binding] of this.pageExecutionBindings) { if (binding.uuid === uuid) this.pageExecutionBindings.delete(handle); } + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.scripts.some((script) => script.uuid === uuid)) this.userScriptBootstraps.delete(token); + } } private issuePageBinding( uuid: string, envTag: "it" | "ct", storageName: string, + allowedAPIs: readonly string[], sender: IGetSender ): ServiceWorkerExecutionBinding { const source = sender.getSender(); @@ -287,6 +359,7 @@ export class RuntimeService { frameId: source?.frameId, documentId: source?.documentId, storageName, + allowedAPIs: new Set(allowedAPIs), requestIds: new Set(), } satisfies ServiceWorkerExecutionBinding; this.pageExecutionBindings.set(handle, binding); @@ -1444,6 +1517,7 @@ export class RuntimeService { } async pageLoad(data: { envTag?: "it" | "ct" } | undefined, sender: IGetSender): Promise { + if (sender.getConnectOrigin?.() === "userScript") return { ok: false }; const chromeSender = sender.getSender(); const url = chromeSender?.url; if (!url) { @@ -1455,6 +1529,10 @@ export class RuntimeService { const incognito = chromeSender.tab?.incognito ?? false; const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); + // Retire bindings even when the new URL has no matching scripts. This closes + // the reuse window on browsers that do not provide documentId. + this.revokePageBindings(sender, data?.envTag); + this.mq.emit("popupPageLoadUpdate", { tabId: tabId, frameId: frameId, @@ -1463,10 +1541,15 @@ export class RuntimeService { }); if (res) { - this.revokePageBindings(sender, data?.envTag); const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => scripts.map((script) => { - const binding = this.issuePageBinding(script.uuid, envTag, getStorageName(script), sender); + const binding = this.issuePageBinding( + script.uuid, + envTag, + getStorageName(script), + getPageRpcAllowedAPIs(script.metadata.grant || []), + sender + ); return { ...script, executionHandle: binding.handle, @@ -1474,12 +1557,27 @@ export class RuntimeService { executionRunFlag: binding.runFlag, }; }); + const injectScriptList = data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"); + const contentScriptList = prepareScripts(res.contentScriptList, "ct"); + let userScriptBootstrapToken: string | undefined; + if (data?.envTag === "it" && contentScriptList.length > 0) { + userScriptBootstrapToken = uuidv4(); + this.userScriptBootstraps.set(userScriptBootstrapToken, { + scripts: contentScriptList, + envInfo: res.envInfo, + extensionOrigin: getExtensionOrigin(), + tabId, + frameId, + documentId: chromeSender.documentId, + }); + } // 返回脚本资料,在页面加载 return { ok: true, - injectScriptList: data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"), - contentScriptList: data?.envTag === "it" ? [] : prepareScripts(res.contentScriptList, "ct"), + injectScriptList, + contentScriptList: data?.envTag === "it" ? [] : contentScriptList, envInfo: res.envInfo, + userScriptBootstrapToken, }; } else { // 没有脚本资料,不需要加载 diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 977f6f0cc..951370783 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -65,6 +65,8 @@ export type ServiceWorkerExecutionBinding = { documentId?: string; /** 用于只向运行该脚本的文档投递值更新的存储命名空间。 */ storageName: string; + /** Capability names accepted by the isolated GM API broker for this page execution. */ + allowedAPIs: ReadonlySet; /** 已接受的页面请求 ID;绑定销毁时一并释放,确保绑定存续期间拒绝重放。 */ requestIds: Set; }; diff --git a/src/content.ts b/src/content.ts index aa5249ffc..6722c19bc 100644 --- a/src/content.ts +++ b/src/content.ts @@ -10,6 +10,9 @@ import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; import { connectUserScriptChannel } from "./app/service/content/user_script_connection"; +import type { TScriptInfo } from "./app/repo/scripts"; +import type { GMInfoEnv } from "./app/service/content/types"; +import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./app/service/content/page_rpc"; const messageFlag = process.env.SC_RANDOM_KEY!; @@ -36,20 +39,45 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const scriptExecutor = new ScriptExecutor(msg, domContentMsg, "serviceWorker"); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); runtime.contentInit(domServer, domMsg); - runtime.init(); - // Keep a native port for callbacks and value updates. The page-observable event - // channel remains limited to the synchronous DOM helper. - void runtime.loadPage(async (scripts) => { - await connectUserScriptChannel( - msg, - scripts.map((script) => script.executionHandle).filter((handle): handle is string => Boolean(handle)), - (_connection, packet) => { - if (packet.action === "content/runtime/valueUpdate") { + domServer.on( + "pageLoad", + (data: { bootstrapToken?: unknown; envInfo?: GMInfoEnv; extensionOrigin?: ExtensionOrigin }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + void connectUserScriptChannel(msg, data.bootstrapToken, (_connection, packet) => { + if (packet.action === "content/pageLoad") { + const packetData = packet.data as { + scripts?: TScriptInfo[]; + envInfo?: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + }; + if ( + !packetData || + !Array.isArray(packetData.scripts) || + packetData.scripts.length === 0 || + !packetData.envInfo + ) { + return; + } + for (let i = 0; i < packetData.scripts.length; i += 1) { + const script = packetData.scripts[i]; + if ( + !script || + typeof script !== "object" || + script.executionEnvTag !== scriptEnvTag || + typeof script.executionHandle !== "string" + ) { + return; + } + } + setPageRpcExtensionOrigin(packetData.extensionOrigin); + runtime.startScripts(packetData.scripts, packetData.envInfo); + } else if (packet.action === "content/runtime/valueUpdate") { scriptExecutor.valueUpdate(packet.data as any); } else if (packet.action === "content/runtime/emitEvent") { scriptExecutor.emitEvent(packet.data as any); } - } - ); - }); + }); + } + ); + runtime.init(); }); From e1ccf0715affb3ca9761192eae457dc49cb8037a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:04:17 +0900 Subject: [PATCH 009/106] =?UTF-8?q?=F0=9F=90=9B=20preserve=20GM=20API=20re?= =?UTF-8?q?gistry=20enumeration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_context.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index c604b7019..0a1bfcd88 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,4 +1,5 @@ import type { ApiParam, ApiValue } from "../types"; +import { Native } from "../global"; const apis: Record = Object.create(null); @@ -7,6 +8,11 @@ export function GMContextApiGet(name: string): ApiValue[] | undefined { return apis[name]; } +// 注册表由装饰器在模块载入时填充,供安装页的支持表守卫枚举全部能力。 +export function GMContextApiNames(): string[] { + return Native.objectKeys(apis); +} + function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 let m: ApiValue[] | undefined = apis[grant]; From 9f98995ef15d5513f1c9808e83bd585a82137b40 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:10:50 +0900 Subject: [PATCH 010/106] =?UTF-8?q?=F0=9F=90=9B=20make=20GM=20API=20regist?= =?UTF-8?q?ry=20merge-compatible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_context.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 0a1bfcd88..87d873357 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,22 +1,30 @@ import type { ApiParam, ApiValue } from "../types"; import { Native } from "../global"; -const apis: Record = Object.create(null); +const apiRegistry: Record = Native.objectCreate(null); +const apis = { + get: (name: string) => apiRegistry[name], + set: (name: string, values: ApiValue[]) => { + apiRegistry[name] = values; + }, + keys: () => Native.objectKeys(apiRegistry), +}; export function GMContextApiGet(name: string): ApiValue[] | undefined { // 回传 Api 列表 - return apis[name]; + return apis.get(name); } -// 注册表由装饰器在模块载入时填充,供安装页的支持表守卫枚举全部能力。 +// 已注册的全部 @grant 名。注册表由装饰器在模块载入时填充,无法静态推导, +// 供 script_compat.ts 的静态支持表做一致性守卫(新增 GM API 若漏进表会被测出来)。 export function GMContextApiNames(): string[] { - return Native.objectKeys(apis); + return [...apis.keys()]; } function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 - let m: ApiValue[] | undefined = apis[grant]; - if (!m) apis[grant] = m = []; + let m: ApiValue[] | undefined = apis.get(grant); + if (!m) apis.set(grant, (m = [])); m[m.length] = { fnKey, api, param }; } From 98685945385d2e816784d9b5552959aa57db0727 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:22:04 +0900 Subject: [PATCH 011/106] =?UTF-8?q?=E2=9A=A1=20reduce=20native=20reflectio?= =?UTF-8?q?n=20overhead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 30 +++--- src/app/service/content/exec_script.ts | 6 +- src/app/service/content/global.ts | 91 +++++++++++++++---- src/app/service/content/gm_api/cat_agent.ts | 17 ++-- .../service/content/gm_api/cat_agent_task.ts | 10 +- src/app/service/content/gm_api/gm_api.ts | 6 +- src/app/service/content/gm_api/gm_xhr.ts | 8 +- 7 files changed, 111 insertions(+), 57 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 99db340ae..414bee3b2 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -11,8 +11,9 @@ import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_ import { Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { - const capability = (...args: any[]) => Native.reflectApply(api, receiver, args); + const capability = Native.bind(api, receiver); Native.objectDefineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); + Native.objectDefineProperty(capability, "length", { configurable: true, value: 0 }); return capability; }; @@ -27,6 +28,7 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { + const scriptGrantSet = Native.createSet(scriptGrants); // 按照GMApi构建 const valueChangeListener = new ListenerManager(); const EE = new EventEmitter(); @@ -77,8 +79,8 @@ export const createContext = ( const grantSet: Set = context.grantSet; const s = GMContextApiGet(grant); if (!s) return false; // @grant 的定义未实现,略过 (返回 false 表示 @grant 不存在) - if (Native.setHas(grantSet, grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) - Native.setAdd(grantSet, grant); + if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) + grantSet.add(grant); for (let i = 0; i < s.length; i += 1) { const { fnKey, api, param } = s[i]; grantedAPIs[fnKey] = createCapability(api, context); @@ -89,7 +91,7 @@ export const createContext = ( } return true; }; - Native.setForEach(scriptGrants, (grant) => { + scriptGrantSet.forEach((grant) => { const candidates = getGrantCandidates(String(grant)); for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; @@ -111,7 +113,7 @@ export const createContext = ( } } context.unsafeWindow = window; - if (Native.setHas(scriptGrants, "window.onurlchange") && context.onurlchange === undefined) { + if (scriptGrantSet.has("window.onurlchange") && context.onurlchange === undefined) { context.onurlchange = null; attachNavigateHandler(window as any); } @@ -241,8 +243,8 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn for (let i = 0; i < keys.length; i += 1) { const key = keys[i]; const desc = descriptors[key]; - if (Native.setHas(descsCache, key)) continue; - Native.setAdd(descsCache, key); // realm own descriptors take precedence over host descriptors + if (descsCache.has(key)) continue; + descsCache.add(key); // realm own descriptors take precedence over host descriptors if ("value" in desc) { // 替换 function 的 this 为实际的 realm global。 @@ -255,7 +257,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 // 例:(window.)onload, (window.)onerror。 - Native.setAdd(eventKeys, key); + eventKeys.add(key); continue; } if (desc.get || desc.set) { @@ -275,16 +277,16 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 // 例:(window.)onload, (window.)onerror。 - Native.setAdd(eventKeys, key); + eventKeys.add(key); return; } - if (Native.setHas(descsCache, key)) return; + if (descsCache.has(key)) return; if ("value" in desc) { // 替换 function 的 this 为实际的 host window。 if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); - Native.setAdd(descsCache, key); + descsCache.add(key); } else if (!(key in initOwnDescs) && !Native.objectHasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } @@ -294,7 +296,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // 替换 getter setter 的 this 为实际的 host window。 // 例:(window.)location, (window.)document。 overriddenDescs[key] = materializeDescriptor(desc, hostWindow); - Native.setAdd(descsCache, key); + descsCache.add(key); } }); }; @@ -303,7 +305,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn collectRealmDescriptors(); // 第二趟 hostWindow:补齐 Firefox split-realm 的 host 成员。 collectHostWindowDescriptors(); - Native.setClear(descsCache); // 内存释放 + descsCache.clear(); // 内存释放 // sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor // OwnPropertyDescriptor定义 为 原OwnPropertyDescriptor定义 (DragEvent, MouseEvent, RegExp, EventTarget, JSON等) @@ -424,7 +426,7 @@ export const createProxyContext = ( }; const eventKeyList: string[] = []; - Native.setForEach(eventKeys, (key) => { + eventKeys.forEach((key) => { eventKeyList[eventKeyList.length] = String(key); }); for (let i = 0; i < eventKeyList.length; i += 1) { diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 34fd9b75d..bd805ec1d 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -53,10 +53,10 @@ export default class ExecScript { } const grantSet = Native.createSet(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { - Native.setAdd(grantSet, "GM_registerMenuCommand"); - Native.setDelete(grantSet, "none"); + grantSet.add("GM_registerMenuCommand"); + grantSet.delete("none"); } - if (Native.setHas(grantSet, "none")) { + if (grantSet.has("none")) { // 不注入任何GM api // ScriptCat行为:GM.info 和 GM_info 同时注入 // 在不改变 Context 的情况下,以 named 传入多个全域变量 diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 53896b47f..f71c7e2c0 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -13,26 +13,90 @@ const nativeSetDelete = Set.prototype.delete; const nativeSetClear = Set.prototype.clear; const nativeSetForEach = Set.prototype.forEach; const nativeMapConstructor = Map; +const nativeMapGet = Map.prototype.get; +const nativeMapSet = Map.prototype.set; +const nativeMapHas = Map.prototype.has; +const nativeMapDelete = Map.prototype.delete; +const nativeMapClear = Map.prototype.clear; +const nativeMapForEach = Map.prototype.forEach; const nativeWeakMapConstructor = WeakMap; const nativeWeakMapGet = WeakMap.prototype.get; const nativeWeakMapSet = WeakMap.prototype.set; +const nativeWeakMapHas = WeakMap.prototype.has; +const nativeWeakMapDelete = WeakMap.prototype.delete; + +const nativeFunctionApply = nativeReflectApply(nativeFunctionBind, Function.prototype.apply, [ + Function.prototype.apply, +]) as (fn: (...args: any[]) => any, receiver: any, args: any[]) => any; +const nativeFunctionCall = nativeReflectApply(nativeFunctionBind, Function.prototype.call, [ + Function.prototype.call, +]) as (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => any; export const nativeApply = (fn: (...args: any[]) => any, receiver: any, args: any[]) => - nativeReflectApply(fn, receiver, args); + nativeFunctionApply(fn, receiver, args); export const nativeCall = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => - nativeReflectApply(fn, receiver, args); + nativeFunctionCall(fn, receiver, ...args); export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => - nativeReflectApply(nativeFunctionBind, fn, [receiver, ...args]); + nativeFunctionCall(nativeFunctionBind, fn, receiver, ...args); + +type SafeSet = Set & { + add: Set["add"]; + has: Set["has"]; + delete: Set["delete"]; + clear: Set["clear"]; + forEach: Set["forEach"]; +}; + +type SafeMap = Map & { + get: Map["get"]; + set: Map["set"]; + has: Map["has"]; + delete: Map["delete"]; + clear: Map["clear"]; + forEach: Map["forEach"]; +}; -const createNativeSet = (values?: readonly T[]): Set => { - const set = new nativeSetConstructor(); - if (values) { - for (let i = 0; i < values.length; i += 1) nativeReflectApply(nativeSetAdd, set, [values[i]]); +type SafeWeakMap = WeakMap & { + get: WeakMap["get"]; + set: WeakMap["set"]; + has: WeakMap["has"]; + delete: WeakMap["delete"]; +}; + +const createNativeSet = (values?: readonly T[] | Set | null): SafeSet => { + const set = new nativeSetConstructor() as SafeSet; + set.add = nativeSetAdd as SafeSet["add"]; + set.has = nativeSetHas as SafeSet["has"]; + set.delete = nativeSetDelete as SafeSet["delete"]; + set.clear = nativeSetClear as SafeSet["clear"]; + set.forEach = nativeSetForEach as SafeSet["forEach"]; + if (Array.isArray(values)) { + for (let i = 0; i < values.length; i += 1) set.add(values[i]); + } else if (values) { + nativeReflectApply(nativeSetForEach, values, [(value: T) => set.add(value)]); } return set; }; -const createNativeWeakMap = (): WeakMap => new nativeWeakMapConstructor(); +const createNativeMap = (): SafeMap => { + const map = new nativeMapConstructor() as SafeMap; + map.get = nativeMapGet as SafeMap["get"]; + map.set = nativeMapSet as SafeMap["set"]; + map.has = nativeMapHas as SafeMap["has"]; + map.delete = nativeMapDelete as SafeMap["delete"]; + map.clear = nativeMapClear as SafeMap["clear"]; + map.forEach = nativeMapForEach as SafeMap["forEach"]; + return map; +}; + +const createNativeWeakMap = (): SafeWeakMap => { + const map = new nativeWeakMapConstructor() as SafeWeakMap; + map.get = nativeWeakMapGet as SafeWeakMap["get"]; + map.set = nativeWeakMapSet as SafeWeakMap["set"]; + map.has = nativeWeakMapHas as SafeWeakMap["has"]; + map.delete = nativeWeakMapDelete as SafeWeakMap["delete"]; + return map; +}; export const Native = { Set: nativeSetConstructor, @@ -57,17 +121,8 @@ export const Native = { objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), reflectGet: nativeBind(Reflect.get, Reflect), - setAdd: (set: Set, value: unknown) => nativeReflectApply(nativeSetAdd, set, [value]), - setHas: (set: Set, value: unknown) => nativeReflectApply(nativeSetHas, set, [value]), - setDelete: (set: Set, value: unknown) => nativeReflectApply(nativeSetDelete, set, [value]), - setClear: (set: Set) => nativeReflectApply(nativeSetClear, set, []), - setForEach: (set: Set, callback: (value: unknown, value2: unknown, set: Set) => void) => - nativeReflectApply(nativeSetForEach, set, [callback]), createSet: createNativeSet, - weakMapGet: (map: WeakMap, key: K) => - nativeReflectApply(nativeWeakMapGet, map, [key]) as V | undefined, - weakMapSet: (map: WeakMap, key: K, value: V) => - nativeReflectApply(nativeWeakMapSet, map, [key, value]) as WeakMap, + createMap: createNativeMap, createWeakMap: createNativeWeakMap, } as const; diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index fbdc67ee2..42fcd5e9c 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -18,8 +18,7 @@ import type { MessageContent, } from "@App/app/service/agent/core/types"; import { getTextContent } from "@App/app/service/agent/core/content_utils"; - -const nativeReflectApply = Reflect.apply; +import { Native } from "../global"; export type ConversationStreamChunk = | StreamChunk @@ -94,12 +93,10 @@ type ConversationPrivateState = { background: boolean; }; -const conversationStates = new WeakMap(); -const weakMapGet = WeakMap.prototype.get; -const weakMapSet = WeakMap.prototype.set; +const conversationStates = Native.createWeakMap(); const getConversationState = (instance: ConversationInstance): ConversationPrivateState => { - const state = nativeReflectApply(weakMapGet, conversationStates, [instance]); + const state = conversationStates.get(instance); if (!state) throw new Error("conversation instance is invalid"); return state; }; @@ -137,7 +134,7 @@ export class ConversationInstance { systemPrompt: system, background: background || false, }; - nativeReflectApply(weakMapSet, conversationStates, [this, state]); + conversationStates.set(this, state); this.ephemeral = ephemeral || false; if (initialTools) { for (const tool of initialTools) { @@ -925,10 +922,12 @@ function buildInstance( conv: Conversation, options?: ConversationCreateOptions ): ConversationInstance { + const sendMessage = Native.bind(ctx.sendMessage, ctx); + const connect = Native.bind(ctx.connect, ctx); return new ConversationInstance( conv, - (api, params) => nativeReflectApply(ctx.sendMessage, ctx, [api, params]), - (api, params) => nativeReflectApply(ctx.connect, ctx, [api, params]), + sendMessage, + connect, ctx.scriptRes?.uuid || "", options?.tools, options?.commands, diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index 83fe76a40..bca205877 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -7,6 +7,7 @@ import type { EventAgentTask, } from "@App/app/service/agent/core/types"; import type EventEmitter from "eventemitter3"; +import { Native } from "../global"; // 运行时 this 是 GM_Base 实例 interface GMBaseContext { @@ -18,16 +19,13 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; -const listenerMaps = new WeakMap(); -const nativeReflectApply = Reflect.apply; -const weakMapGet = WeakMap.prototype.get; -const weakMapSet = WeakMap.prototype.set; +const listenerMaps = Native.createWeakMap(); const getListenerRecords = (owner: object): ListenerRecord[] => { - let records = nativeReflectApply(weakMapGet, listenerMaps, [owner]); + let records = listenerMaps.get(owner); if (!records) { records = []; - nativeReflectApply(weakMapSet, listenerMaps, [owner, records]); + listenerMaps.set(owner, records); } return records; }; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b46488713..b3df05a08 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -65,10 +65,10 @@ const valueChangePromiseMap: Record void> = Object.create(null); const notificationTagMaps = Native.createWeakMap>(); const getNotificationTagMap = (owner: object): Map => { - let map = Native.weakMapGet(notificationTagMaps, owner); + let map = notificationTagMaps.get(owner); if (!map) { - map = new Native.Map(); - Native.weakMapSet(notificationTagMaps, owner, map); + map = Native.createMap(); + notificationTagMaps.set(owner, map); } return map; }; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index cb3220ef4..8eb758feb 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -148,7 +148,7 @@ interface RetStateFnRecord { const xhrResponseGetters = { response: { get() { - const retTemp = Native.weakMapGet(retStateFnMap, this); + const retTemp = retStateFnMap.get(this); return retTemp?.getResponse(); }, enumerable: false, @@ -156,7 +156,7 @@ const xhrResponseGetters = { }, responseXML: { get() { - const retTemp = Native.weakMapGet(retStateFnMap, this); + const retTemp = retStateFnMap.get(this); return retTemp?.getResponseXML(); }, enumerable: false, @@ -164,7 +164,7 @@ const xhrResponseGetters = { }, responseText: { get() { - const retTemp = Native.weakMapGet(retStateFnMap, this); + const retTemp = retStateFnMap.get(this); return retTemp?.getResponseText(); }, enumerable: false, @@ -435,7 +435,7 @@ export function GM_xmlhttpRequest( const retParamObject: GMXHRResponseType = Native.objectCreate(null, descriptors); // 外部没引用 retParamObject 时,retTemp 会被自动GC const retTemp = makeRetTemp(contentType); - Native.weakMapSet(retStateFnMap, retParamObject, retTemp); + retStateFnMap.set(retParamObject, retTemp); return retParamObject; }; From f49053afa7b35f6c6a99d882673f55cd1c4b11bf Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:29:33 +0900 Subject: [PATCH 012/106] =?UTF-8?q?=E2=9A=A1=20reduce=20content=20callback?= =?UTF-8?q?=20overhead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 4 +-- src/app/service/content/global.ts | 6 ++-- src/app/service/content/gm_api/gm_api.ts | 10 +++--- src/app/service/content/gm_api/gm_xhr.ts | 39 ++++++++++++----------- 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 414bee3b2..8bf3e9520 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -8,7 +8,7 @@ import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; import { createGMBase } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; -import { Native } from "./global"; +import { nativeCall, Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { const capability = Native.bind(api, receiver); @@ -393,7 +393,7 @@ export const createProxyContext = ( hostRemoveEventListener(eventName, eventObject); this.fn = null; } else { - Native.call(fn, mySandbox, event); + nativeCall(fn, mySandbox, event); } }, }; diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index f71c7e2c0..51ae2c95c 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -32,10 +32,8 @@ const nativeFunctionCall = nativeReflectApply(nativeFunctionBind, Function.proto Function.prototype.call, ]) as (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => any; -export const nativeApply = (fn: (...args: any[]) => any, receiver: any, args: any[]) => - nativeFunctionApply(fn, receiver, args); -export const nativeCall = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => - nativeFunctionCall(fn, receiver, ...args); +export const nativeApply = nativeFunctionApply; +export const nativeCall = nativeFunctionCall; export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => nativeFunctionCall(nativeFunctionBind, fn, receiver, ...args); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b3df05a08..b85c7dbe5 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1,4 +1,4 @@ -import { customClone, Native } from "../global"; +import { customClone, nativeApply, Native } from "../global"; import type { Message, MessageConnect } from "@Packages/message/types"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type { @@ -1345,7 +1345,7 @@ export default class GMApi extends GM_Base { gmApi.sendMessage("GM_notification", [customClone(data), notificationId]).then((id) => { if (!gmApi.EE) return; if (create) { - Native.apply(create, { id }, [id]); + nativeApply(create, { id }, [id]); } if (typeof data.tag === "string") { notificationTagMap.set(data.tag, id); @@ -1382,8 +1382,8 @@ export default class GMApi extends GM_Base { title: data.title, url: data.url, }; - click && Native.apply(click, { id }, [clickEvent]); - done && Native.apply(done, { id }, []); + click && nativeApply(click, { id }, [clickEvent]); + done && nativeApply(done, { id }, []); if (!isPreventDefault) { if (typeof data.url === "string") { @@ -1396,7 +1396,7 @@ export default class GMApi extends GM_Base { break; } case "close": { - done && Native.apply(done, { id }, [resp.params.byUser]); + done && nativeApply(done, { id }, [resp.params.byUser]); clearNotificationIdMap(); gmApi.EE.removeAllListeners("GM_notification:" + gmApi.eventId); break; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 8eb758feb..28d00a620 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -137,6 +137,17 @@ const docParseTypes = Native.createSet([ const retStateFnMap = Native.createWeakMap(); +const invokeXHRCallback = (name: string, callback: ((value: any) => void) | undefined, value: any) => { + if (!callback) return; + try { + callback(value); + } catch (error) { + // User callback failures are reported without rejecting the internal + // message queue or interrupting request settlement. + LoggerCore.logger().error("GM_xmlhttpRequest callback failed", { name, ...Logger.E(error) }); + } +}; + interface RetStateFnRecord { getResponseText(): string | undefined; getResponseXML(): Document | null | undefined; @@ -506,16 +517,6 @@ export function GM_xmlhttpRequest( return makeResponseRet(retParam, addGetters, res.contentType); }; - const invokeCallback = (name: string, callback: ((value: any) => void) | undefined, value: any) => { - if (!callback) return; - try { - callback(value); - } catch (error) { - // User callback failures are reported without rejecting the internal - // message queue or interrupting request settlement. - LoggerCore.logger().error("GM_xmlhttpRequest callback failed", { name, ...Logger.E(error) }); - } - }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; let loadendCalled = false; const doLoadEnd = (data: TXhrCallBackArg) => { @@ -532,7 +533,7 @@ export function GM_xmlhttpRequest( retPromiseReject?.(errorOccur); } refCleanup?.(); - invokeCallback("onloadend", details.onloadend, xhrResponse); + invokeXHRCallback("onloadend", details.onloadend, xhrResponse); } }; const scheduleSyntheticLoadEnd = () => { @@ -550,7 +551,7 @@ export function GM_xmlhttpRequest( reqDone = true; // Mark the request settled before user code runs. A throwing abort // callback must not leave the broker connection and loadend cleanup pending. - invokeCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // doAbort 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 Abort 流程 @@ -586,7 +587,7 @@ export function GM_xmlhttpRequest( if (!reqDone) { errorOccur = message; reqDone = true; - invokeCallback("onerror", details.onerror, { + invokeXHRCallback("onerror", details.onerror, { readyState: ReadyStateCode.DONE, error: message, }); @@ -668,14 +669,14 @@ export function GM_xmlhttpRequest( break; } case "onload": - invokeCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { doLoadEnd(data); break; } case "onloadstart": - invokeCallback("onloadstart", details.onloadstart, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onloadstart", details.onloadstart, makeXHRCallbackParam?.(data) ?? {}); break; case "onprogress": { if (details.onprogress) { @@ -687,7 +688,7 @@ export function GM_xmlhttpRequest( done: data.loaded, totalSize: data.total, }; - invokeCallback("onprogress", details.onprogress, res); + invokeXHRCallback("onprogress", details.onprogress, res); } break; } @@ -700,14 +701,14 @@ export function GM_xmlhttpRequest( // readable stream 的 controller 可以释放 controller = undefined; // GC用 } - invokeCallback("onreadystatechange", details.onreadystatechange, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onreadystatechange", details.onreadystatechange, makeXHRCallbackParam?.(data) ?? {}); break; } case "ontimeout": if (!reqDone) { errorOccur = "TimeoutError"; reqDone = true; - invokeCallback("ontimeout", details.ontimeout, makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("ontimeout", details.ontimeout, makeXHRCallbackParam?.(data) ?? {}); scheduleSyntheticLoadEnd(); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); @@ -718,7 +719,7 @@ export function GM_xmlhttpRequest( data.error ||= "Unknown Error"; errorOccur = data.error; reqDone = true; - invokeCallback( + invokeXHRCallback( "onerror", details.onerror, (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError From 47c27049cfa0c7e1138571d3418228f5e0fe7311 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:45:22 +0900 Subject: [PATCH 013/106] =?UTF-8?q?=E2=9A=A1=20avoid=20receiver=20binding?= =?UTF-8?q?=20for=20pure=20agent=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 11 ++++--- .../content/gm_api/cat_agent_model.test.ts | 9 +++--- .../service/content/gm_api/cat_agent_model.ts | 20 +++++-------- .../content/gm_api/cat_agent_opfs.test.ts | 20 ++++++------- .../service/content/gm_api/cat_agent_opfs.ts | 30 +++++++++++-------- src/app/service/content/types.ts | 2 ++ 6 files changed, 49 insertions(+), 43 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 8bf3e9520..31724ad3a 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -10,9 +10,12 @@ import { createGMBase } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; import { nativeCall, Native } from "./global"; -const createCapability = (api: (...args: any[]) => any, receiver: object) => { - const capability = Native.bind(api, receiver); - Native.objectDefineProperty(capability, "name", { configurable: true, value: `bound ${api.name}` }); +const createCapability = (api: (...args: any[]) => any, receiver: object, bind = true) => { + const capability = bind ? Native.bind(api, receiver) : (...args: any[]) => api(receiver, ...args); + Native.objectDefineProperty(capability, "name", { + configurable: true, + value: `${bind ? "bound " : ""}${api.name}`, + }); Native.objectDefineProperty(capability, "length", { configurable: true, value: 0 }); return capability; }; @@ -83,7 +86,7 @@ export const createContext = ( grantSet.add(grant); for (let i = 0; i < s.length; i += 1) { const { fnKey, api, param } = s[i]; - grantedAPIs[fnKey] = createCapability(api, context); + grantedAPIs[fnKey] = createCapability(api, context, param?.bind !== false); const depend = param?.depend; if (depend) { for (let j = 0; j < depend.length; j += 1) __methodInject__(depend[j]); diff --git a/src/app/service/content/gm_api/cat_agent_model.test.ts b/src/app/service/content/gm_api/cat_agent_model.test.ts index 614342ae4..94a90dd2e 100644 --- a/src/app/service/content/gm_api/cat_agent_model.test.ts +++ b/src/app/service/content/gm_api/cat_agent_model.test.ts @@ -15,6 +15,7 @@ describe.concurrent("CATAgentModelApi", () => { expect(fnKeys).toContain("CAT.agent.model.list"); expect(fnKeys).toContain("CAT.agent.model.get"); expect(fnKeys).toContain("CAT.agent.model.getDefault"); + expect(apis!.every((api) => api.param.bind === false)).toBe(true); }); it.concurrent("list 方法调用 sendMessage 并传递正确的请求", async () => { @@ -31,7 +32,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; - const result = await listApi.api.call(ctx); + const result = await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "list", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -57,7 +58,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const getApi = apis.find((a) => a.fnKey === "CAT.agent.model.get")!; - const result = await getApi.api.call(ctx, "m1"); + const result = await getApi.api(ctx, "m1"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "get", id: "m1", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -75,7 +76,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const getDefaultApi = apis.find((a) => a.fnKey === "CAT.agent.model.getDefault")!; - const result = await getDefaultApi.api.call(ctx); + const result = await getDefaultApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "getDefault", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -93,7 +94,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "list", scriptUuid: "" } as ModelApiRequest, diff --git a/src/app/service/content/gm_api/cat_agent_model.ts b/src/app/service/content/gm_api/cat_agent_model.ts index 3cc53c8e8..e876dea9b 100644 --- a/src/app/service/content/gm_api/cat_agent_model.ts +++ b/src/app/service/content/gm_api/cat_agent_model.ts @@ -22,33 +22,29 @@ export default class CATAgentModelApi { @GMContext.protected() protected scriptRes?: { uuid: string }; - @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.model", bind: false }) + public "CAT.agent.model.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } - @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.get"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.model", bind: false }) + public "CAT.agent.model.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } - @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.getDefault"(): Promise { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.model", bind: false }) + public "CAT.agent.model.getDefault"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getDefault", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } - @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.getSummary"(): Promise { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.model", bind: false }) + public "CAT.agent.model.getSummary"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getSummary", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; diff --git a/src/app/service/content/gm_api/cat_agent_opfs.test.ts b/src/app/service/content/gm_api/cat_agent_opfs.test.ts index d189ccf6e..0b87bfea2 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.test.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.test.ts @@ -24,7 +24,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const writeApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.write")!; - const result = await writeApi.api.call(ctx, "hello.txt", "Hello"); + const result = await writeApi.api(ctx, "hello.txt", "Hello"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "write", path: "hello.txt", content: "Hello", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -38,7 +38,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "f.txt"); + const result = await readApi.api(ctx, "f.txt"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "read", path: "f.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -54,14 +54,14 @@ describe.concurrent("CATAgentOPFSApi", () => { const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; // 不带 path - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: undefined, scriptUuid: "test-uuid" } as OPFSApiRequest, ]); // 带 path mockSendMessage.mockClear(); - await listApi.api.call(ctx, "sub"); + await listApi.api(ctx, "sub"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: "sub", scriptUuid: "test-uuid" } as OPFSApiRequest, ]); @@ -73,7 +73,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const deleteApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.delete")!; - const result = await deleteApi.api.call(ctx, "old.txt"); + const result = await deleteApi.api(ctx, "old.txt"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "delete", path: "old.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -87,7 +87,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: undefined, scriptUuid: "" } as OPFSApiRequest, @@ -108,7 +108,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; - const result = await readAttachmentApi.api.call(ctx, "att-1"); + const result = await readAttachmentApi.api(ctx, "att-1"); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect((result as any).data).toBe(testBlob); @@ -126,7 +126,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "img.png", "blob"); + const result = await readApi.api(ctx, "img.png", "blob"); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect((result as any).data).toBe(testBlob); @@ -154,7 +154,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; - const result = await readAttachmentApi.api.call(ctx, "att-1"); + const result = await readAttachmentApi.api(ctx, "att-1"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/123"]); expect((result as any).data).toBe(testBlob); @@ -181,7 +181,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "img.png", "blob"); + const result = await readApi.api(ctx, "img.png", "blob"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/456"]); expect((result as any).data).toBe(testBlob); diff --git a/src/app/service/content/gm_api/cat_agent_opfs.ts b/src/app/service/content/gm_api/cat_agent_opfs.ts index 6355e5517..d3cfc5e81 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.ts @@ -16,20 +16,23 @@ export default class CATAgentOPFSApi { @GMContext.protected() protected scriptRes?: { uuid: string }; - @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.write"(path: string, content: string | Blob): Promise<{ path: string; size: number }> { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + public "CAT.agent.opfs.write"( + ctx: GMBaseContext, + path: string, + content: string | Blob + ): Promise<{ path: string; size: number }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "write", path, content, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise<{ path: string; size: number }>; } - @GMContext.API({ follow: "CAT.agent.opfs" }) + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) public async "CAT.agent.opfs.read"( + ctx: GMBaseContext, path: string, format?: "text" | "blob" ): Promise<{ path: string; content?: string; data?: Blob; size: number; mimeType?: string }> { - const ctx = this as unknown as GMBaseContext; const result = await ctx.sendMessage("CAT_agentOPFS", [ { action: "read", path, format, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]); @@ -41,19 +44,21 @@ export default class CATAgentOPFSApi { return result; } - @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.list"(path?: string): Promise> { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + public "CAT.agent.opfs.list"( + ctx: GMBaseContext, + path?: string + ): Promise> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "list", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise>; } - @GMContext.API({ follow: "CAT.agent.opfs" }) + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) public async "CAT.agent.opfs.readAttachment"( + ctx: GMBaseContext, id: string ): Promise<{ id: string; data: Blob; size: number; mimeType?: string }> { - const ctx = this as unknown as GMBaseContext; const result = await ctx.sendMessage("CAT_agentOPFS", [ { action: "readAttachment", id, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]); @@ -65,9 +70,8 @@ export default class CATAgentOPFSApi { return result; } - @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.delete"(path: string): Promise<{ success: true }> { - const ctx = this as unknown as GMBaseContext; + @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + public "CAT.agent.opfs.delete"(ctx: GMBaseContext, path: string): Promise<{ success: true }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "delete", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise<{ success: true }>; diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 61d0f060f..0f50887a5 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -38,6 +38,8 @@ export interface ApiParam { follow?: string; depend?: string[]; alias?: string; + /** API receives its GM context as the first argument instead of via `this`. */ + bind?: boolean; } export interface ApiValue { From 8bcad36c9cedd2f46c95b5a67a6d97144113382c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:48:07 +0900 Subject: [PATCH 014/106] =?UTF-8?q?=E2=9A=A1=20capture=20native=20collecti?= =?UTF-8?q?on=20methods=20on=20subclasses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 17 ++++++ src/app/service/content/global.ts | 57 +++++++++++-------- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index c5f9f8b70..f90a5dd7f 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -4,6 +4,7 @@ import { encodeRValue } from "@App/pkg/utils/message_value"; import { createContext, createProxyContext, shouldFnBind, type RealmRoots } from "./create_context"; import { GMContextApiGet } from "./gm_api/gm_context"; import { trimScriptInfo } from "./utils"; +import { Native } from "./global"; type AnyRecord = Record; @@ -305,6 +306,22 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("creates collection instances from frozen captured-method subclasses", () => { + const set = Native.createSet(["grant"]); + const map = Native.createMap(); + const weakMap = Native.createWeakMap(); + + expect(set).toBeInstanceOf(Native.Set); + expect(map).toBeInstanceOf(Native.Map); + expect(weakMap).toBeInstanceOf(Native.WeakMap); + expect(Object.hasOwn(Object.getPrototypeOf(set), "add")).toBe(true); + expect(Object.hasOwn(Object.getPrototypeOf(map), "get")).toBe(true); + expect(Object.hasOwn(Object.getPrototypeOf(weakMap), "get")).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(set))).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(map))).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(weakMap))).toBe(true); + }); + it("keeps grant construction on captured Set and iterator intrinsics", () => { const NativeSet = Set; const nativeArrayIterator = Array.prototype[Symbol.iterator]; diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 51ae2c95c..9b43fcc10 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -24,6 +24,34 @@ const nativeWeakMapGet = WeakMap.prototype.get; const nativeWeakMapSet = WeakMap.prototype.set; const nativeWeakMapHas = WeakMap.prototype.has; const nativeWeakMapDelete = WeakMap.prototype.delete; +const nativeObjectFreeze = Object.freeze; + +// Keep the captured methods on private subclasses. Instances can then be created +// without reassigning every method, while the subclass prototypes remain outside +// the page's mutable built-in prototypes. +const NativeSetConstructor = class extends nativeSetConstructor {}; +NativeSetConstructor.prototype.add = nativeSetAdd; +NativeSetConstructor.prototype.has = nativeSetHas; +NativeSetConstructor.prototype.delete = nativeSetDelete; +NativeSetConstructor.prototype.clear = nativeSetClear; +NativeSetConstructor.prototype.forEach = nativeSetForEach; +nativeObjectFreeze(NativeSetConstructor.prototype); + +const NativeMapConstructor = class extends nativeMapConstructor {}; +NativeMapConstructor.prototype.get = nativeMapGet; +NativeMapConstructor.prototype.set = nativeMapSet; +NativeMapConstructor.prototype.has = nativeMapHas; +NativeMapConstructor.prototype.delete = nativeMapDelete; +NativeMapConstructor.prototype.clear = nativeMapClear; +NativeMapConstructor.prototype.forEach = nativeMapForEach; +nativeObjectFreeze(NativeMapConstructor.prototype); + +const NativeWeakMapConstructor = class extends nativeWeakMapConstructor {}; +NativeWeakMapConstructor.prototype.get = nativeWeakMapGet; +NativeWeakMapConstructor.prototype.set = nativeWeakMapSet; +NativeWeakMapConstructor.prototype.has = nativeWeakMapHas; +NativeWeakMapConstructor.prototype.delete = nativeWeakMapDelete; +nativeObjectFreeze(NativeWeakMapConstructor.prototype); const nativeFunctionApply = nativeReflectApply(nativeFunctionBind, Function.prototype.apply, [ Function.prototype.apply, @@ -62,12 +90,7 @@ type SafeWeakMap = WeakMap & { }; const createNativeSet = (values?: readonly T[] | Set | null): SafeSet => { - const set = new nativeSetConstructor() as SafeSet; - set.add = nativeSetAdd as SafeSet["add"]; - set.has = nativeSetHas as SafeSet["has"]; - set.delete = nativeSetDelete as SafeSet["delete"]; - set.clear = nativeSetClear as SafeSet["clear"]; - set.forEach = nativeSetForEach as SafeSet["forEach"]; + const set = new NativeSetConstructor() as SafeSet; if (Array.isArray(values)) { for (let i = 0; i < values.length; i += 1) set.add(values[i]); } else if (values) { @@ -77,29 +100,17 @@ const createNativeSet = (values?: readonly T[] | Set | null): SafeSet = }; const createNativeMap = (): SafeMap => { - const map = new nativeMapConstructor() as SafeMap; - map.get = nativeMapGet as SafeMap["get"]; - map.set = nativeMapSet as SafeMap["set"]; - map.has = nativeMapHas as SafeMap["has"]; - map.delete = nativeMapDelete as SafeMap["delete"]; - map.clear = nativeMapClear as SafeMap["clear"]; - map.forEach = nativeMapForEach as SafeMap["forEach"]; - return map; + return new NativeMapConstructor() as SafeMap; }; const createNativeWeakMap = (): SafeWeakMap => { - const map = new nativeWeakMapConstructor() as SafeWeakMap; - map.get = nativeWeakMapGet as SafeWeakMap["get"]; - map.set = nativeWeakMapSet as SafeWeakMap["set"]; - map.has = nativeWeakMapHas as SafeWeakMap["has"]; - map.delete = nativeWeakMapDelete as SafeWeakMap["delete"]; - return map; + return new NativeWeakMapConstructor() as SafeWeakMap; }; export const Native = { - Set: nativeSetConstructor, - Map: nativeMapConstructor, - WeakMap: nativeWeakMapConstructor, + Set: NativeSetConstructor, + Map: NativeMapConstructor, + WeakMap: NativeWeakMapConstructor, apply: nativeApply, call: nativeCall, bind: nativeBind, From f2aad91956b7d439e6e928d711bb1e393830a84f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:00:00 +0900 Subject: [PATCH 015/106] =?UTF-8?q?=E2=9A=A1=20remove=20GM=20API=20receive?= =?UTF-8?q?r=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 2 +- src/app/service/content/create_context.ts | 9 +- src/app/service/content/gm_api/cat_agent.ts | 26 +- .../service/content/gm_api/cat_agent_dom.ts | 59 +-- .../content/gm_api/cat_agent_model.test.ts | 1 - .../service/content/gm_api/cat_agent_model.ts | 10 +- .../service/content/gm_api/cat_agent_opfs.ts | 12 +- .../content/gm_api/cat_agent_skills.ts | 15 +- .../service/content/gm_api/cat_agent_task.ts | 33 +- src/app/service/content/gm_api/gm_api.test.ts | 70 +-- src/app/service/content/gm_api/gm_api.ts | 420 ++++++++++-------- .../gm_api/related_target_lifecycle.test.ts | 6 +- src/app/service/content/types.ts | 2 - tests/runtime/gm_api.test.ts | 24 +- 14 files changed, 361 insertions(+), 328 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index f90a5dd7f..717d5d62a 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -385,7 +385,7 @@ describe("createContext: capability and lifecycle contract", () => { it("installs capabilities without looking up a page-patchable Function.prototype.bind", () => { const apiValues = GMContextApiGet("GM_getValue")!; const originalApi = apiValues[0].api; - const replacement = function (this: unknown, key: string, fallback?: unknown) { + const replacement = function (_ctx: unknown, key: string, fallback?: unknown) { return fallback; }; Object.defineProperty(replacement, "bind", { configurable: true, value: undefined }); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 31724ad3a..22c3960e9 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -10,11 +10,12 @@ import { createGMBase } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; import { nativeCall, Native } from "./global"; -const createCapability = (api: (...args: any[]) => any, receiver: object, bind = true) => { - const capability = bind ? Native.bind(api, receiver) : (...args: any[]) => api(receiver, ...args); +const createCapability = (api: (...args: any[]) => any, receiver: object) => { + // 由闭包提供上下文,脚本侧只传 API 自身的参数。 + const capability = (...args: any[]) => api(receiver, ...args); Native.objectDefineProperty(capability, "name", { configurable: true, - value: `${bind ? "bound " : ""}${api.name}`, + value: api.name, }); Native.objectDefineProperty(capability, "length", { configurable: true, value: 0 }); return capability; @@ -86,7 +87,7 @@ export const createContext = ( grantSet.add(grant); for (let i = 0; i < s.length; i += 1) { const { fnKey, api, param } = s[i]; - grantedAPIs[fnKey] = createCapability(api, context, param?.bind !== false); + grantedAPIs[fnKey] = createCapability(api, context); const depend = param?.depend; if (depend) { for (let j = 0; j < depend.length; j += 1) __methodInject__(depend[j]); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 42fcd5e9c..e32f911c8 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -908,15 +908,14 @@ export class ConversationInstance { } } -// 运行时 this 是 GM_Base 实例,定义其实际拥有的字段类型 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; connect: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; } -// 构建 ConversationInstance,独立函数避免 this 绑定问题 -// (装饰器方法运行时 this 是 GM_Base 实例,不是 CATAgentApi) +// 构建 ConversationInstance,保留 GM_Base 的消息上下文。 function buildInstance( ctx: GMBaseContext, conv: Conversation, @@ -953,7 +952,10 @@ export default class CATAgentApi { // CAT.agent.conversation.create() @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.create"(options: ConversationCreateOptions = {}): Promise { + public "CAT.agent.conversation.create"( + ctx: GMBaseContext, + options: ConversationCreateOptions = {} + ): Promise { return (async () => { if (options.ephemeral) { // ephemeral 模式:不发请求到 SW,直接在脚本端构造 @@ -965,26 +967,26 @@ export default class CATAgentApi { createtime: Date.now(), updatetime: Date.now(), }; - return buildInstance(this as unknown as GMBaseContext, conv, options); + return buildInstance(ctx as unknown as GMBaseContext, conv, options); } const { tools: _tools, ephemeral: _ephemeral, ...serverOptions } = options; - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "create", options: serverOptions, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + const conv = (await ctx.sendMessage("CAT_agentConversation", [ + { action: "create", options: serverOptions, scriptUuid: ctx.scriptRes?.uuid || "" } as ConversationApiRequest, ])) as Conversation; - return buildInstance(this as unknown as GMBaseContext, conv, options); + return buildInstance(ctx as unknown as GMBaseContext, conv, options); })(); } // CAT.agent.conversation.get() @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.get"(id: string): Promise { + public "CAT.agent.conversation.get"(ctx: GMBaseContext, id: string): Promise { return (async () => { - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "get", id, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + const conv = (await ctx.sendMessage("CAT_agentConversation", [ + { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ConversationApiRequest, ])) as Conversation | null; if (!conv) return null; - return buildInstance(this as unknown as GMBaseContext, conv); + return buildInstance(ctx as unknown as GMBaseContext, conv); })(); } } diff --git a/src/app/service/content/gm_api/cat_agent_dom.ts b/src/app/service/content/gm_api/cat_agent_dom.ts index 1208d39ed..914b16f37 100644 --- a/src/app/service/content/gm_api/cat_agent_dom.ts +++ b/src/app/service/content/gm_api/cat_agent_dom.ts @@ -23,7 +23,7 @@ import type { MonitorStatus, } from "@App/app/service/agent/core/types"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; @@ -37,96 +37,105 @@ export default class CATAgentDomApi { protected scriptRes?: any; @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.listTabs"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.listTabs"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "listTabs", scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.navigate"(url: string, options?: NavigateOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.navigate"(ctx: GMBaseContext, url: string, options?: NavigateOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "navigate", url, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.readPage"(options?: ReadPageOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.readPage"(ctx: GMBaseContext, options?: ReadPageOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "readPage", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.screenshot"(options?: ScreenshotOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.screenshot"(ctx: GMBaseContext, options?: ScreenshotOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "screenshot", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.click"(selector: string, options?: DomActionOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.click"( + ctx: GMBaseContext, + selector: string, + options?: DomActionOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "click", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.fill"(selector: string, value: string, options?: DomActionOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.fill"( + ctx: GMBaseContext, + selector: string, + value: string, + options?: DomActionOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "fill", selector, value, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.scroll"(direction: ScrollDirection, options?: ScrollOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.scroll"( + ctx: GMBaseContext, + direction: ScrollDirection, + options?: ScrollOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "scroll", direction, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.waitFor"(selector: string, options?: WaitForOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.waitFor"( + ctx: GMBaseContext, + selector: string, + options?: WaitForOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "waitFor", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.executeScript"(code: string, options?: ExecuteScriptOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.executeScript"( + ctx: GMBaseContext, + code: string, + options?: ExecuteScriptOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "executeScript", code, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.startMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.startMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "startMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.stopMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.stopMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "stopMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.peekMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.peekMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "peekMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); diff --git a/src/app/service/content/gm_api/cat_agent_model.test.ts b/src/app/service/content/gm_api/cat_agent_model.test.ts index 94a90dd2e..f9746766f 100644 --- a/src/app/service/content/gm_api/cat_agent_model.test.ts +++ b/src/app/service/content/gm_api/cat_agent_model.test.ts @@ -15,7 +15,6 @@ describe.concurrent("CATAgentModelApi", () => { expect(fnKeys).toContain("CAT.agent.model.list"); expect(fnKeys).toContain("CAT.agent.model.get"); expect(fnKeys).toContain("CAT.agent.model.getDefault"); - expect(apis!.every((api) => api.param.bind === false)).toBe(true); }); it.concurrent("list 方法调用 sendMessage 并传递正确的请求", async () => { diff --git a/src/app/service/content/gm_api/cat_agent_model.ts b/src/app/service/content/gm_api/cat_agent_model.ts index e876dea9b..fe3d7d5f2 100644 --- a/src/app/service/content/gm_api/cat_agent_model.ts +++ b/src/app/service/content/gm_api/cat_agent_model.ts @@ -1,7 +1,7 @@ import type { AgentModelSafeConfig, ModelApiRequest } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: ( api: string, @@ -22,28 +22,28 @@ export default class CATAgentModelApi { @GMContext.protected() protected scriptRes?: { uuid: string }; - @GMContext.API({ follow: "CAT.agent.model", bind: false }) + @GMContext.API({ follow: "CAT.agent.model" }) public "CAT.agent.model.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } - @GMContext.API({ follow: "CAT.agent.model", bind: false }) + @GMContext.API({ follow: "CAT.agent.model" }) public "CAT.agent.model.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } - @GMContext.API({ follow: "CAT.agent.model", bind: false }) + @GMContext.API({ follow: "CAT.agent.model" }) public "CAT.agent.model.getDefault"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getDefault", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } - @GMContext.API({ follow: "CAT.agent.model", bind: false }) + @GMContext.API({ follow: "CAT.agent.model" }) public "CAT.agent.model.getSummary"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getSummary", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, diff --git a/src/app/service/content/gm_api/cat_agent_opfs.ts b/src/app/service/content/gm_api/cat_agent_opfs.ts index d3cfc5e81..549919162 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.ts @@ -1,7 +1,7 @@ import type { OPFSApiRequest } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: any[]) => Promise; scriptRes?: { uuid: string }; @@ -16,7 +16,7 @@ export default class CATAgentOPFSApi { @GMContext.protected() protected scriptRes?: { uuid: string }; - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public "CAT.agent.opfs.write"( ctx: GMBaseContext, path: string, @@ -27,7 +27,7 @@ export default class CATAgentOPFSApi { ]) as Promise<{ path: string; size: number }>; } - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.read"( ctx: GMBaseContext, path: string, @@ -44,7 +44,7 @@ export default class CATAgentOPFSApi { return result; } - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public "CAT.agent.opfs.list"( ctx: GMBaseContext, path?: string @@ -54,7 +54,7 @@ export default class CATAgentOPFSApi { ]) as Promise>; } - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.readAttachment"( ctx: GMBaseContext, id: string @@ -70,7 +70,7 @@ export default class CATAgentOPFSApi { return result; } - @GMContext.API({ follow: "CAT.agent.opfs", bind: false }) + @GMContext.API({ follow: "CAT.agent.opfs" }) public "CAT.agent.opfs.delete"(ctx: GMBaseContext, path: string): Promise<{ success: true }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "delete", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, diff --git a/src/app/service/content/gm_api/cat_agent_skills.ts b/src/app/service/content/gm_api/cat_agent_skills.ts index 8e9dd6314..be7715338 100644 --- a/src/app/service/content/gm_api/cat_agent_skills.ts +++ b/src/app/service/content/gm_api/cat_agent_skills.ts @@ -1,7 +1,7 @@ import type { SkillApiRequest, SkillRecord, SkillSummary } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: ( api: string, @@ -23,16 +23,14 @@ export default class CATAgentSkillsApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.get"(name: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.get"(ctx: GMBaseContext, name: string): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "get", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; @@ -40,11 +38,11 @@ export default class CATAgentSkillsApi { @GMContext.API({ follow: "CAT.agent.skills" }) public "CAT.agent.skills.install"( + ctx: GMBaseContext, skillMd: string, scripts?: Array<{ name: string; code: string }>, references?: Array<{ name: string; content: string }> ): Promise { - const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "install", @@ -57,8 +55,7 @@ export default class CATAgentSkillsApi { } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.remove"(name: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.remove"(ctx: GMBaseContext, name: string): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "remove", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; @@ -66,11 +63,11 @@ export default class CATAgentSkillsApi { @GMContext.API({ follow: "CAT.agent.skills" }) public "CAT.agent.skills.call"( + ctx: GMBaseContext, skillName: string, scriptName: string, params?: Record ): Promise { - const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "call", diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index bca205877..3ef602884 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -9,7 +9,7 @@ import type { import type EventEmitter from "eventemitter3"; import { Native } from "../global"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; @@ -43,11 +43,11 @@ export default class CATAgentTaskApi { @GMContext.API({ follow: "CAT.agent.task" }) public "CAT.agent.task.create"( + ctx: GMBaseContext, options: | Omit | Omit ): Promise { - const ctx = this as unknown as GMBaseContext; // event 模式:自动注入 sourceScriptUuid(脚本无需手动传入) const task = options.mode === "event" ? { ...options, sourceScriptUuid: ctx.scriptRes?.uuid || "" } : { ...options }; @@ -60,14 +60,12 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "list" } as AgentTaskApiRequest]) as Promise; } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.get"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "get", id } as AgentTaskApiRequest]) as Promise< AgentTask | undefined >; @@ -76,8 +74,7 @@ export default class CATAgentTaskApi { // task 必须携带 get()/list() 返回的 generation/revision(乐观并发版本号), // 否则服务端无法区分"修改的是当前这个任务"还是"ID 被删除重建后的另一个任务" @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.update"(id: string, task: Partial): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.update"(ctx: GMBaseContext, id: string, task: Partial): Promise { if (task.generation === undefined || task.revision === undefined) { throw new Error( "CAT.agent.task.update: task must include the generation/revision returned by CAT.agent.task.get() or list() — spread the fetched task before applying changes." @@ -89,8 +86,11 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.remove"(id: string, task: Pick): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.remove"( + ctx: GMBaseContext, + id: string, + task: Pick + ): Promise { if (task?.generation === undefined || task?.revision === undefined) { throw new Error( "CAT.agent.task.remove: task must include the generation/revision returned by CAT.agent.task.get() or list()." @@ -102,16 +102,18 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.runNow"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.runNow"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "runNow", id } as AgentTaskApiRequest]) as Promise; } // 监听任务触发事件 // 利用 EE.on("agentTask:{taskId}", callback) 注册监听 @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.addListener"(taskId: string, callback: (trigger: AgentTaskTrigger) => void): number { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.addListener"( + ctx: GMBaseContext, + taskId: string, + callback: (trigger: AgentTaskTrigger) => void + ): number { if (!ctx.EE) return 0; const listenerId = ++listenerCounter; @@ -129,8 +131,7 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.removeListener"(listenerId: number): void { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.removeListener"(ctx: GMBaseContext, listenerId: number): void { if (!ctx.EE) return; const records = getListenerRecords(ctx); 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 5f0629f39..94f3dfb70 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -140,10 +140,10 @@ describe("GM Resource API", () => { } 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="); + expect(api.GM_getResourceText(api, name)).toBe("declared resource"); + expect(api.GM_getResourceURL(api, name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + expect(await api["GM.getResourceText"](api, name)).toBe("declared resource"); + expect(await api["GM.getResourceUrl"](api, name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); const legacyScript = { ...script, @@ -152,7 +152,7 @@ describe("GM Resource API", () => { } as unknown as ScriptRunResource; const legacyApi = new GMApi("test", {} as Message, {} as Message, legacyScript); - expect(legacyApi.GM_getResourceText(name)).toBe("legacy resource"); + expect(legacyApi.GM_getResourceText(legacyApi, name)).toBe("legacy resource"); }); }); @@ -198,23 +198,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); // getValue - expect(ret.GM_getValue?.name).toEqual("bound GM_getValue"); + expect(ret.GM_getValue?.name).toEqual("GM_getValue"); // getTab / getTabs / saveTab - expect(ret.GM_getTab?.name).toEqual("bound GM_getTab"); - expect(ret.GM_getTabs?.name).toEqual("bound GM_getTabs"); - expect(ret.GM_saveTab?.name).toEqual("bound GM_saveTab"); + expect(ret.GM_getTab?.name).toEqual("GM_getTab"); + expect(ret.GM_getTabs?.name).toEqual("GM_getTabs"); + expect(ret.GM_saveTab?.name).toEqual("GM_saveTab"); // cookie - expect(ret.GM_cookie?.name).toEqual("bound GM_cookie"); - expect(ret["GM_cookie.list"]?.name).toEqual("bound GM_cookie.list"); + expect(ret.GM_cookie?.name).toEqual("GM_cookie"); + expect(ret["GM_cookie.list"]?.name).toEqual("GM_cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -260,23 +260,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); // getValue - expect(ret["GM.getValue"]?.name).toEqual("bound GM.getValue"); + expect(ret["GM.getValue"]?.name).toEqual("GM.getValue"); // getTab / getTabs / saveTab - expect(ret["GM.getTab"]?.name).toEqual("bound GM.getTab"); - expect(ret["GM.getTabs"]?.name).toEqual("bound GM.getTabs"); - expect(ret["GM.saveTab"]?.name).toEqual("bound GM.saveTab"); + expect(ret["GM.getTab"]?.name).toEqual("GM.getTab"); + expect(ret["GM.getTabs"]?.name).toEqual("GM.getTabs"); + expect(ret["GM.saveTab"]?.name).toEqual("GM.saveTab"); // cookie - expect(ret["GM.cookie"]?.name).toEqual("bound GM.cookie"); - expect(ret["GM.cookie"]?.list?.name).toEqual("bound GM.cookie.list"); + expect(ret["GM.cookie"]?.name).toEqual("GM.cookie"); + expect(ret["GM.cookie"]?.list?.name).toEqual("GM.cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -1301,7 +1301,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 script.metadata.grant = ["GM_getValue", "GM_addValueChangeListener"]; script.value = {}; const api = new GMApi("test", {} as Message, {} as Message, script); - api.GM_addValueChangeListener("snapshot", (_name, _oldValue, newValue) => { + api.GM_addValueChangeListener(api, "snapshot", (_name, _oldValue, newValue) => { const snapshot = newValue as { nested: { value: number } }; snapshot.nested.value = 99; }); @@ -1314,7 +1314,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 valueUpdated: true, }); - expect(api.GM_getValue("snapshot")).toEqual({ nested: { value: 1 } }); + expect(api.GM_getValue(api, "snapshot")).toEqual({ nested: { value: 1 } }); }); it.concurrent("异步GM.setValue,等待回调", 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 b85c7dbe5..a1d47cc09 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -313,15 +313,15 @@ export default class GMApi extends GM_Base { // 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间 @GMContext.API() - public GM_getValue(key: string, defaultValue?: any) { - return _GM_getValue(this, key, defaultValue); + public GM_getValue(ctx: GMApi, key: string, defaultValue?: any) { + return _GM_getValue(ctx, key, defaultValue); } @GMContext.API() - public "GM.getValue"(key: string, defaultValue?: any): Promise { + public "GM.getValue"(ctx: GMApi, key: string, defaultValue?: any): Promise { // 兼容GM.getValue return new Promise((resolve) => { - const ret = _GM_getValue(this, key, defaultValue); + const ret = _GM_getValue(ctx, key, defaultValue); resolve(ret); }); } @@ -390,62 +390,62 @@ export default class GMApi extends GM_Base { } @GMContext.API() - public GM_setValue(key: string, value: any) { - _GM_setValue(this, null, key, value); + public GM_setValue(ctx: GMApi, key: string, value: any) { + _GM_setValue(ctx, null, key, value); } @GMContext.API() - public "GM.setValue"(key: string, value: any): Promise { + public "GM.setValue"(ctx: GMApi, key: string, value: any): Promise { // Asynchronous wrapper for GM_setValue to support GM.setValue return new Promise((resolve) => { - _GM_setValue(this, resolve, key, value); + _GM_setValue(ctx, resolve, key, value); }); } @GMContext.API() - public GM_deleteValue(key: string): void { - _GM_setValue(this, null, key, undefined); + public GM_deleteValue(ctx: GMApi, key: string): void { + _GM_setValue(ctx, null, key, undefined); } @GMContext.API() - public "GM.deleteValue"(key: string): Promise { + public "GM.deleteValue"(ctx: GMApi, key: string): Promise { // Asynchronous wrapper for GM_deleteValue to support GM.deleteValue return new Promise((resolve) => { - _GM_setValue(this, resolve, key, undefined); + _GM_setValue(ctx, resolve, key, undefined); }); } @GMContext.API() - public GM_listValues(): string[] { - if (!this.scriptRes) return []; - const keys = Object.keys(this.scriptRes.value); + public GM_listValues(ctx: GMApi): string[] { + if (!ctx.scriptRes) return []; + const keys = Object.keys(ctx.scriptRes.value); return keys; } @GMContext.API() - public "GM.listValues"(): Promise { + public "GM.listValues"(ctx: GMApi): Promise { // Asynchronous wrapper for GM_listValues to support GM.listValues return new Promise((resolve) => { - if (!this.scriptRes) return resolve([]); - const keys = Object.keys(this.scriptRes.value); + if (!ctx.scriptRes) return resolve([]); + const keys = Object.keys(ctx.scriptRes.value); resolve(keys); }); } @GMContext.API() - public GM_setValues(values: TGMKeyValue) { + public GM_setValues(ctx: GMApi, values: TGMKeyValue) { if (!values || typeof values !== "object") { throw new Error("GM_setValues: values must be an object"); } - _GM_setValues(this, null, values); + _GM_setValues(ctx, null, values); } @GMContext.API() - public GM_getValues(keysOrDefaults: TGMKeyValue | string[] | null | undefined) { - if (!this.scriptRes) return {}; + public GM_getValues(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined) { + if (!ctx.scriptRes) return {}; if (!keysOrDefaults) { // Returns all values - return customClone(this.scriptRes.value)!; + return customClone(ctx.scriptRes.value)!; } const result: TGMKeyValue = {}; if (Array.isArray(keysOrDefaults)) { @@ -453,9 +453,9 @@ export default class GMApi extends GM_Base { // Handle array of keys (e.g., ['foo', 'bar']) for (let index = 0; index < keysOrDefaults.length; index++) { const key = keysOrDefaults[index]; - if (key in this.scriptRes.value) { + if (key in ctx.scriptRes.value) { // 对object的value进行一次转化 - let value = this.scriptRes.value[key]; + let value = ctx.scriptRes.value[key]; if (value && typeof value === "object") { value = customClone(value)!; } @@ -467,7 +467,7 @@ export default class GMApi extends GM_Base { // Handle object with default values (e.g., { foo: 1, bar: 2, baz: 3 }) for (const key of Object.keys(keysOrDefaults)) { const defaultValue = keysOrDefaults[key]; - result[key] = _GM_getValue(this, key, defaultValue); + result[key] = _GM_getValue(ctx, key, defaultValue); } } return result; @@ -475,28 +475,28 @@ export default class GMApi extends GM_Base { // Asynchronous wrapper for GM.getValues @GMContext.API({ depend: ["GM_getValues"] }) - public "GM.getValues"(keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.getValues"(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { - const ret = this.GM_getValues(keysOrDefaults); + const ret = GMApi.prototype.GM_getValues(ctx, keysOrDefaults); resolve(ret); }); } @GMContext.API() - public "GM.setValues"(values: { [key: string]: any }): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.setValues"(ctx: GMApi, values: { [key: string]: any }): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { if (!values || typeof values !== "object") { throw new Error("GM.setValues: values must be an object"); } - _GM_setValues(this, resolve, values); + _GM_setValues(ctx, resolve, values); }); } @GMContext.API() - public GM_deleteValues(keys: string[]) { - if (!this.scriptRes) return; + public GM_deleteValues(ctx: GMApi, keys: string[]) { + if (!ctx.scriptRes) return; if (!Array.isArray(keys)) { console.warn("GM_deleteValues: keys must be string[]"); return; @@ -505,13 +505,13 @@ export default class GMApi extends GM_Base { for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, null, req); + _GM_setValues(ctx, null, req); } // Asynchronous wrapper for GM.deleteValues @GMContext.API() - public "GM.deleteValues"(keys: string[]): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.deleteValues"(ctx: GMApi, keys: string[]): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { if (!Array.isArray(keys)) { throw new Error("GM.deleteValues: keys must be string[]"); @@ -520,77 +520,83 @@ export default class GMApi extends GM_Base { for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, resolve, req); + _GM_setValues(ctx, resolve, req); } }); } @GMContext.API() - public GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number { - if (!this.valueChangeListener) return 0; - return this.valueChangeListener.add(name, listener); + public GM_addValueChangeListener(ctx: GMApi, name: string, listener: GMTypes.ValueChangeListener): number { + if (!ctx.valueChangeListener) return 0; + return ctx.valueChangeListener.add(name, listener); } @GMContext.API({ depend: ["GM_addValueChangeListener"] }) - public "GM.addValueChangeListener"(name: string, listener: GMTypes.ValueChangeListener): Promise { + public "GM.addValueChangeListener"(ctx: GMApi, name: string, listener: GMTypes.ValueChangeListener): Promise { return new Promise((resolve) => { - const ret = this.GM_addValueChangeListener(name, listener); + const ret = GMApi.prototype.GM_addValueChangeListener(ctx, name, listener); resolve(ret); }); } @GMContext.API() - public GM_removeValueChangeListener(listenerId: number): void { - if (!this.valueChangeListener) return; - this.valueChangeListener.remove(listenerId); + public GM_removeValueChangeListener(ctx: GMApi, listenerId: number): void { + if (!ctx.valueChangeListener) return; + ctx.valueChangeListener.remove(listenerId); } @GMContext.API({ depend: ["GM_removeValueChangeListener"] }) - public "GM.removeValueChangeListener"(listenerId: number): Promise { + public "GM.removeValueChangeListener"(ctx: GMApi, listenerId: number): Promise { return new Promise((resolve) => { - this.GM_removeValueChangeListener(listenerId); + GMApi.prototype.GM_removeValueChangeListener(ctx, listenerId); resolve(); }); } @GMContext.API() - public GM_log(message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[]): void { - if (this.isInvalidContext()) return; + public GM_log( + ctx: GMApi, + message: string, + level: GMTypes.LoggerLevel = "info", + ...labels: GMTypes.LoggerLabel[] + ): void { + if (ctx.isInvalidContext()) return; if (typeof message !== "string") { message = Native.jsonStringify(message); } - this.sendMessage("GM_log", [`${message}`, `${level}`, labels]); + ctx.sendMessage("GM_log", [`${message}`, `${level}`, labels]); } @GMContext.API({ depend: ["GM_log"] }) public "GM.log"( + ctx: GMApi, message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[] ): Promise { return new Promise((resolve) => { - this.GM_log(message, level, ...labels); + GMApi.prototype.GM_log(ctx, message, level, ...labels); resolve(); }); } @GMContext.API() - public CAT_createBlobUrl(blob: Blob): Promise { - return Promise.resolve(toBlobURL(this, blob)); + public CAT_createBlobUrl(ctx: GMApi, blob: Blob): Promise { + return Promise.resolve(toBlobURL(ctx, blob)); } // 辅助GM_xml获取blob数据 @GMContext.API() - public CAT_fetchBlob(url: string): Promise { - return this.sendMessage("CAT_fetchBlob", [`${url}`]); + public CAT_fetchBlob(ctx: GMApi, url: string): Promise { + return ctx.sendMessage("CAT_fetchBlob", [`${url}`]); } @GMContext.API() - public async CAT_fetchDocument(url: string): Promise { + public async CAT_fetchDocument(ctx: GMApi, url: string): Promise { // 上下文已失效时直接返回,避免访问已释放的 message 造成异常 - if (this.isInvalidContext()) return undefined; + if (ctx.isInvalidContext()) return undefined; - if (this.scriptRes?.executionEnvTag === ScriptEnvTag.content) { + if (ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content) { return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; @@ -601,9 +607,9 @@ export default class GMApi extends GM_Base { }); } - const message = this.message as CustomEventMessage | null; + const message = ctx.message as CustomEventMessage | null; const isContentEnv = !!message && message.envTag === ScriptEnvTag.content; - return urlToDocumentInContentPage(this, url, isContentEnv); + return urlToDocumentInContentPage(ctx, url, isContentEnv); } static _GM_cookie( @@ -640,36 +646,36 @@ export default class GMApi extends GM_Base { } @GMContext.API() - public "GM.cookie"(action: string, details: GMTypes.CookieDetails) { + public "GM.cookie"(ctx: GMApi, action: string, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, action, details, (cookie, error) => { + _GM_cookie(ctx, action, details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.set"(details: GMTypes.CookieDetails) { + public "GM.cookie.set"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "set", details, (cookie, error) => { + _GM_cookie(ctx, "set", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.list"(details: GMTypes.CookieDetails) { + public "GM.cookie.list"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "list", details, (cookie, error) => { + _GM_cookie(ctx, "list", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.delete"(details: GMTypes.CookieDetails) { + public "GM.cookie.delete"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "delete", details, (cookie, error) => { + _GM_cookie(ctx, "delete", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); @@ -677,35 +683,39 @@ export default class GMApi extends GM_Base { @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.set"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "set", details, done); + _GM_cookie(ctx, "set", details, done); } @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.list"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "list", details, done); + _GM_cookie(ctx, "list", details, done); } @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.delete"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "delete", details, done); + _GM_cookie(ctx, "delete", details, done); } @GMContext.API() public GM_cookie( + ctx: GMApi, action: string, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, action, details, done); + _GM_cookie(ctx, action, details, done); } // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 @@ -727,13 +737,14 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_registerMenuCommand( + ctx: GMApi, name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string ): TScriptMenuItemID { - if (!this.EE) return -1; - execEnvInit(this); - this.regMenuCounter! += 1; + if (!ctx.EE) return -1; + execEnvInit(ctx); + ctx.regMenuCounter! += 1; // 兼容 GM_registerMenuCommand(name, options_or_accessKey) if (!options_or_accessKey && typeof listener === "object") { options_or_accessKey = listener; @@ -752,7 +763,7 @@ export default class GMApi extends GM_Base { if (isIndividual === undefined && isSeparator) { isIndividual = true; } - options.mIndividualKey = isIndividual ? this.regMenuCounter : 0; + options.mIndividualKey = isIndividual ? ctx.regMenuCounter : 0; if (options.autoClose === undefined) { options.autoClose = true; } @@ -769,52 +780,56 @@ export default class GMApi extends GM_Base { } let providedId: string | number | undefined = typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined; - if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id + if (providedId === undefined) providedId = ctx.menuIdCounter! += 1; // 如无指定,使用累计器id const ret = providedId! as TScriptMenuItemID; providedId = `t${providedId!}`; // 见 TScriptMenuItemID 注释 - providedId = `${this.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + providedId = `${ctx.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 const menuKey = providedId; // menuKey为唯一键:{环境识别符}.t{注册ID} // 检查之前有否注册 - if (menuKey && this.menuKeyRegistered!.has(menuKey)) { + if (menuKey && ctx.menuKeyRegistered!.has(menuKey)) { // 有注册过,先移除 listeners - this.EE.removeAllListeners("menuClick:" + menuKey); + ctx.EE.removeAllListeners("menuClick:" + menuKey); } else { // 没注册过,先记录一下 - this.menuKeyRegistered!.add(menuKey); + ctx.menuKeyRegistered!.add(menuKey); } if (listener) { // GM_registerMenuCommand("hi", undefined, {accessKey:"h"}) 时TM不会报错 - this.EE.addListener("menuClick:" + menuKey, listener); + ctx.EE.addListener("menuClick:" + menuKey, listener); } // 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定) - this.sendMessage("GM_registerMenuCommand", [menuKey, `${name}`, options] as GMRegisterMenuCommandParam); + ctx.sendMessage("GM_registerMenuCommand", [menuKey, `${name}`, options] as GMRegisterMenuCommandParam); return ret; } @GMContext.API({ depend: ["GM_registerMenuCommand"] }) public "GM.registerMenuCommand"( + ctx: GMApi, name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string ): Promise { return new Promise((resolve) => { - const ret = this.GM_registerMenuCommand(name, listener, options_or_accessKey); + const ret = GMApi.prototype.GM_registerMenuCommand(ctx, name, listener, options_or_accessKey); resolve(ret); }); } @GMContext.API({ depend: ["GM_registerMenuCommand"] }) - public CAT_registerMenuInput(...args: Parameters): TScriptMenuItemID { - return this.GM_registerMenuCommand(...args); + public CAT_registerMenuInput( + ctx: GMApi, + ...args: [name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string] + ): TScriptMenuItemID { + return GMApi.prototype.GM_registerMenuCommand(ctx, ...args); } @GMContext.API() - public GM_addStyle(css: string): Element | undefined { - if (!this.message || !this.scriptRes) return; + public GM_addStyle(ctx: GMApi, css: string): Element | undefined { + if (!ctx.message || !ctx.scriptRes) return; if (typeof css !== "string") throw new Error("The parameter 'css' of GM_addStyle shall be a string."); // 与content页的消息通讯实际是同步,此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise - const resp = (this.contentMsg).syncSendMessage({ + const resp = (ctx.contentMsg).syncSendMessage({ action: `content/runtime/addElement`, data: { params: [ @@ -829,24 +844,25 @@ export default class GMApi extends GM_Base { if (resp.code) { throw new Error(resp.message); } - return (this.contentMsg).getAndDelRelatedTarget(resp.data) as Element; + return (ctx.contentMsg).getAndDelRelatedTarget(resp.data) as Element; } @GMContext.API({ depend: ["GM_addStyle"] }) - public "GM.addStyle"(css: string): Promise { + public "GM.addStyle"(ctx: GMApi, css: string): Promise { return new Promise((resolve) => { - const ret = this.GM_addStyle(css); + const ret = GMApi.prototype.GM_addStyle(ctx, css); resolve(ret); }); } @GMContext.API() public GM_addElement( + ctx: GMApi, parentNode: Node | string, tagName: string | Record, attrs: Record | null = {} ): Element | undefined { - if (!this.message || !this.scriptRes) return; + if (!ctx.message || !ctx.scriptRes) return; // 与content页的消息通讯实际是同步, 此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise // 在content脚本执行的话,与直接 DOM 无异 @@ -856,7 +872,7 @@ export default class GMApi extends GM_Base { let parentNodeId: number | null; if (typeof parentNode !== "string") { - const id = (this.contentMsg).sendRelatedTarget(parentNode); + const id = (ctx.contentMsg).sendRelatedTarget(parentNode); parentNodeId = id; } else { parentNodeId = null; @@ -885,7 +901,7 @@ export default class GMApi extends GM_Base { // 使用contentMsg同步发送消息到content脚本,由content脚本创建元素并返回 // 不使用message,因为message是在scripting环境处理的,会因为扩展的 CSP 而无法操作 DOM - const resp = (this.contentMsg).syncSendMessage({ + const resp = (ctx.contentMsg).syncSendMessage({ action: `content/runtime/addElement`, data: { params: [parentNodeId, tagName, attrsCT], @@ -895,7 +911,7 @@ export default class GMApi extends GM_Base { throw new Error(resp.message); } - const el = (this.contentMsg).getAndDelRelatedTarget(resp.data) as Element; + const el = (ctx.contentMsg).getAndDelRelatedTarget(resp.data) as Element; // 设置属性 for (const [key, value] of Object.entries(setAttr)) { (el as any)[key] = value; @@ -907,34 +923,35 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_addElement"] }) public "GM.addElement"( + ctx: GMApi, parentNode: Node | string, tagName: string | Record, attrs: Record | null = {} ): Promise { return new Promise((resolve) => { - const ret = this.GM_addElement(parentNode, tagName, attrs); + const ret = GMApi.prototype.GM_addElement(ctx, parentNode, tagName, attrs); resolve(ret); }); } @GMContext.API() - public GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void { - if (!this.EE) return; - if (!this.contentEnvKey) { + public GM_unregisterMenuCommand(ctx: GMApi, menuId: TScriptMenuItemID): void { + if (!ctx.EE) return; + if (!ctx.contentEnvKey) { return; } let menuKey = `t${menuId}`; // 见 TScriptMenuItemID 注释 - menuKey = `${this.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 - this.menuKeyRegistered!.delete(menuKey); - this.EE.removeAllListeners("menuClick:" + menuKey); + menuKey = `${ctx.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + ctx.menuKeyRegistered!.delete(menuKey); + ctx.EE.removeAllListeners("menuClick:" + menuKey); // 发送至 service worker 处理(唯一键) - this.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); + ctx.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); } @GMContext.API({ depend: ["GM_unregisterMenuCommand"] }) - public "GM.unregisterMenuCommand"(menuId: TScriptMenuItemID): Promise { + public "GM.unregisterMenuCommand"(ctx: GMApi, menuId: TScriptMenuItemID): Promise { return new Promise((resolve) => { - this.GM_unregisterMenuCommand(menuId); + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); resolve(); }); } @@ -942,21 +959,21 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_unregisterMenuCommand"], }) - public CAT_unregisterMenuInput(...args: Parameters): void { - this.GM_unregisterMenuCommand(...args); + public CAT_unregisterMenuInput(ctx: GMApi, menuId: TScriptMenuItemID): void { + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); } @GMContext.API() - public CAT_userConfig() { - return this.sendMessage("CAT_userConfig", []); + public CAT_userConfig(ctx: GMApi) { + return ctx.sendMessage("CAT_userConfig", []); } @GMContext.API({ depend: ["CAT_fetchBlob"], }) - public async CAT_fileStorage(action: "list" | "download" | "upload" | "delete" | "config", details: any) { + public async CAT_fileStorage(ctx: GMApi, action: "list" | "download" | "upload" | "delete" | "config", details: any) { if (action === "config") { - this.sendMessage("CAT_fileStorage", ["config"]); + ctx.sendMessage("CAT_fileStorage", ["config"]); return; } const sendDetails: CATType.CATFileStorageDetails = { @@ -966,44 +983,42 @@ export default class GMApi extends GM_Base { file: details.file, }; if (action === "upload") { - const url = await toBlobURL(this, details.data); + const url = await toBlobURL(ctx, details.data); sendDetails.data = url; } - this.sendMessage("CAT_fileStorage", [`${action}`, sendDetails]).then( - async (resp: { action: string; data: any }) => { - switch (resp.action) { - case "onload": { - if (action === "download") { - // 读取blob - const blob = await this.CAT_fetchBlob(resp.data); - details.onload && details.onload(blob); - } else { - details.onload && details.onload(resp.data); - } - break; + ctx.sendMessage("CAT_fileStorage", [`${action}`, sendDetails]).then(async (resp: { action: string; data: any }) => { + switch (resp.action) { + case "onload": { + if (action === "download") { + // 读取blob + const blob = await GMApi.prototype.CAT_fetchBlob(ctx, resp.data); + details.onload && details.onload(blob); + } else { + details.onload && details.onload(resp.data); } - case "error": { - if (typeof resp.data.code === "undefined") { - details.onerror && details.onerror({ code: -1, message: resp.data.message }); - return; - } - details.onerror && details.onerror(resp.data); + break; + } + case "error": { + if (typeof resp.data.code === "undefined") { + details.onerror && details.onerror({ code: -1, message: resp.data.message }); + return; } + details.onerror && details.onerror(resp.data); } } - ); + }); } // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API() - public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { - const { abort } = GM_xmlhttpRequest(this, details, false); + public GM_xmlhttpRequest(ctx: GMApi, details: GMTypes.XHRDetails) { + const { abort } = GM_xmlhttpRequest(ctx, details, false); return { abort }; } @GMContext.API() - public "GM.xmlHttpRequest"(details: GMTypes.XHRDetails): Promise & GMRequestHandle { - const { retPromise, abort } = GM_xmlhttpRequest(this, details, true); + public "GM.xmlHttpRequest"(ctx: GMApi, details: GMTypes.XHRDetails): Promise & GMRequestHandle { + const { retPromise, abort } = GM_xmlhttpRequest(ctx, details, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1277,16 +1292,16 @@ export default class GMApi extends GM_Base { // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API() - public GM_download(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + public GM_download(ctx: GMApi, arg1: GMTypes.DownloadDetails | string, arg2?: string) { const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; - const { abort } = _GM_download(this, details as GMTypes.DownloadDetails, false); + const { abort } = _GM_download(ctx, details as GMTypes.DownloadDetails, false); return { abort }; } @GMContext.API() - public "GM.download"(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + public "GM.download"(ctx: GMApi, arg1: GMTypes.DownloadDetails | string, arg2?: string) { const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; - const { retPromise, abort } = _GM_download(this, details as GMTypes.DownloadDetails, true); + const { retPromise, abort } = _GM_download(ctx, details as GMTypes.DownloadDetails, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1414,39 +1429,41 @@ export default class GMApi extends GM_Base { @GMContext.API() public async "GM.notification"( + ctx: GMApi, detail: GMTypes.NotificationDetails | string, ondone?: GMTypes.NotificationOnDone | string, image?: string, onclick?: GMTypes.NotificationOnClick ): Promise { - return _GM_notification(this, detail, ondone, image, onclick); + return _GM_notification(ctx, detail, ondone, image, onclick); } @GMContext.API() public GM_notification( + ctx: GMApi, detail: GMTypes.NotificationDetails | string, ondone?: GMTypes.NotificationOnDone | string, image?: string, onclick?: GMTypes.NotificationOnClick ): void { - _GM_notification(this, detail, ondone, image, onclick); + _GM_notification(ctx, detail, ondone, image, onclick); } // ScriptCat 额外API @GMContext.API({ alias: "GM.closeNotification" }) - public GM_closeNotification(id: string): void { - this.sendMessage("GM_closeNotification", [`${id}`]); + public GM_closeNotification(ctx: GMApi, id: string): void { + ctx.sendMessage("GM_closeNotification", [`${id}`]); } // ScriptCat 额外API @GMContext.API({ alias: "GM.updateNotification" }) - public GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void { - this.sendMessage("GM_updateNotification", [`${id}`, customClone(details)]); + public GM_updateNotification(ctx: GMApi, id: string, details: GMTypes.NotificationDetails): void { + ctx.sendMessage("GM_updateNotification", [`${id}`, customClone(details)]); } @GMContext.API({ depend: ["GM_closeInTab"] }) - public GM_openInTab(url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { - if (this.isInvalidContext()) return undefined; + public GM_openInTab(ctx: GMApi, url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { + if (ctx.isInvalidContext()) return undefined; let option = {} as GMTypes.OpenTabOptions; if (typeof param === "boolean") { option.active = !param; // Greasemonkey 3.x loadInBackground @@ -1470,19 +1487,19 @@ export default class GMApi extends GM_Base { const ret: GMTypes.Tab = { close: () => { - tabid && this.GM_closeInTab(tabid); + tabid && GMApi.prototype.GM_closeInTab(ctx, tabid); }, closed: false, // 占位 onclose() {}, }; - this.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { - if (!this.EE) return; + ctx.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { + if (!ctx.EE) return; if (id) { tabid = id; - this.EE.addListener("GM_openInTab:" + id, (resp: any) => { - if (!this.EE) return; + ctx.EE.addListener("GM_openInTab:" + id, (resp: any) => { + if (!ctx.EE) return; switch (resp.event) { case "oncreate": tabid = resp.tabId; @@ -1490,7 +1507,7 @@ export default class GMApi extends GM_Base { case "onclose": ret.onclose && ret.onclose(); ret.closed = true; - this.EE.removeAllListeners("GM_openInTab:" + id); + ctx.EE.removeAllListeners("GM_openInTab:" + id); break; default: LoggerCore.logger().warn("GM_openInTab resp is error", { @@ -1509,74 +1526,78 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_openInTab", "GM_closeInTab"] }) - public "GM.openInTab"(url: string, param?: GMTypes.OpenTabOptions | boolean): Promise { + public "GM.openInTab"( + ctx: GMApi, + url: string, + param?: GMTypes.OpenTabOptions | boolean + ): Promise { return new Promise((resolve) => { - const ret = this.GM_openInTab(url, param); + const ret = GMApi.prototype.GM_openInTab(ctx, url, param); resolve(ret); }); } // ScriptCat 额外API @GMContext.API({ alias: "GM.closeInTab" }) - public GM_closeInTab(tabid: string) { - if (this.isInvalidContext()) return; - return this.sendMessage("GM_closeInTab", [tabid]); + public GM_closeInTab(ctx: GMApi, tabid: string) { + if (ctx.isInvalidContext()) return; + return ctx.sendMessage("GM_closeInTab", [tabid]); } @GMContext.API() - public GM_getTab(callback: (tabData: object) => void) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTab", []).then((tabData) => { + public GM_getTab(ctx: GMApi, callback: (tabData: object) => void) { + if (ctx.isInvalidContext()) return; + ctx.sendMessage("GM_getTab", []).then((tabData) => { callback(tabData ?? {}); }); } @GMContext.API({ depend: ["GM_getTab"] }) - public "GM.getTab"(): Promise { + public "GM.getTab"(ctx: GMApi): Promise { return new Promise((resolve) => { - this.GM_getTab((data) => { + GMApi.prototype.GM_getTab(ctx, (data) => { resolve(data); }); }); } @GMContext.API() - public GM_saveTab(tabData: object): void { - if (this.isInvalidContext()) return; + public GM_saveTab(ctx: GMApi, tabData: object): void { + if (ctx.isInvalidContext()) return; if (typeof tabData === "object") { tabData = customClone(tabData); } - this.sendMessage("GM_saveTab", [tabData]); + ctx.sendMessage("GM_saveTab", [tabData]); } @GMContext.API({ depend: ["GM_saveTab"] }) - public "GM.saveTab"(tabData: object): Promise { + public "GM.saveTab"(ctx: GMApi, tabData: object): Promise { return new Promise((resolve) => { - this.GM_saveTab(tabData); + GMApi.prototype.GM_saveTab(ctx, tabData); resolve(); }); } @GMContext.API() - public GM_getTabs(callback: (tabsData: { [key: number]: object }) => any) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTabs", []).then((tabsData) => { + public GM_getTabs(ctx: GMApi, callback: (tabsData: { [key: number]: object }) => any) { + if (ctx.isInvalidContext()) return; + ctx.sendMessage("GM_getTabs", []).then((tabsData) => { callback(tabsData); }); } @GMContext.API({ depend: ["GM_getTabs"] }) - public "GM.getTabs"(): Promise<{ [key: number]: object }> { + public "GM.getTabs"(ctx: GMApi): Promise<{ [key: number]: object }> { return new Promise<{ [key: number]: object }>((resolve) => { - this.GM_getTabs((tabsData) => { + GMApi.prototype.GM_getTabs(ctx, (tabsData) => { resolve(tabsData); }); }); } @GMContext.API() - public GM_setClipboard(data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { - if (this.isInvalidContext()) return; + public GM_setClipboard(ctx: GMApi, data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { + if (ctx.isInvalidContext()) return; // 物件参数意义不明。日后再检视特殊处理 // 未支持 TM4.19+ application/octet-stream // 参考: https://github.com/Tampermonkey/tampermonkey/issues/1250 @@ -1589,7 +1610,8 @@ export default class GMApi extends GM_Base { else if (mimetype === "html") mimetype = "text/html"; } data = `${data}`; // 强制 string type - this.sendMessage("GM_setClipboard", [data, mimetype]) + ctx + .sendMessage("GM_setClipboard", [data, mimetype]) .then(() => { if (typeof cb === "function") { cb(); @@ -1603,18 +1625,22 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_setClipboard"] }) - public "GM.setClipboard"(data: string, info?: string | { type?: string; mimetype?: string }): Promise { - if (this.isInvalidContext()) return new Promise(() => {}); + public "GM.setClipboard"( + ctx: GMApi, + data: string, + info?: string | { type?: string; mimetype?: string } + ): Promise { + if (ctx.isInvalidContext()) return new Promise(() => {}); return new Promise((resolve) => { - this.GM_setClipboard(data, info, () => { + GMApi.prototype.GM_setClipboard(ctx, data, info, () => { resolve(); }); }); } @GMContext.API() - public GM_getResourceText(name: string): string | undefined { - const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; + public GM_getResourceText(ctx: GMApi, name: string): string | undefined { + const r = (ctx.scriptRes?.resourceByType?.resource ?? ctx.scriptRes?.resource)?.[name]; if (r) { return r.content; } @@ -1622,17 +1648,17 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_getResourceText"] }) - public "GM.getResourceText"(name: string): Promise { + public "GM.getResourceText"(ctx: GMApi, name: string): Promise { // Asynchronous wrapper for GM_getResourceText to support GM.getResourceText return new Promise((resolve) => { - const ret = this.GM_getResourceText(name); + const ret = GMApi.prototype.GM_getResourceText(ctx, name); resolve(ret); }); } @GMContext.API() - public GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined { - const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; + public GM_getResourceURL(ctx: GMApi, name: string, isBlobUrl?: boolean): string | undefined { + const r = (ctx.scriptRes?.resourceByType?.resource ?? ctx.scriptRes?.resource)?.[name]; if (r) { let base64 = r.base64; if (!base64) { @@ -1648,39 +1674,39 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceURL"(name: string, isBlobUrl?: boolean): Promise { + public "GM.getResourceURL"(ctx: GMApi, name: string, isBlobUrl?: boolean): Promise { return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); + const ret = GMApi.prototype.GM_getResourceURL(ctx, name, isBlobUrl); resolve(ret); }); } // GM_getResourceURL的异步版本,用来兼容GM.getResourceUrl @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceUrl"(name: string, isBlobUrl?: boolean): Promise { + public "GM.getResourceUrl"(ctx: GMApi, name: string, isBlobUrl?: boolean): Promise { // Asynchronous wrapper for GM_getResourceURL to support GM.getResourceURL return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); + const ret = GMApi.prototype.GM_getResourceURL(ctx, name, isBlobUrl); resolve(ret); }); } @GMContext.API() - public "window.close"() { - return this.sendMessage("window.close", []); + public "window.close"(ctx: GMApi) { + return ctx.sendMessage("window.close", []); } @GMContext.API() - public "window.focus"() { - return this.sendMessage("window.focus", []); + public "window.focus"(ctx: GMApi) { + return ctx.sendMessage("window.focus", []); } @GMContext.protected() apiLoadPromise: Promise | undefined; @GMContext.API() - public CAT_scriptLoaded() { - return this.loadScriptPromise; + public CAT_scriptLoaded(ctx: GMApi) { + return ctx.loadScriptPromise; } } diff --git a/src/app/service/content/gm_api/related_target_lifecycle.test.ts b/src/app/service/content/gm_api/related_target_lifecycle.test.ts index 722111552..e2af52e95 100644 --- a/src/app/service/content/gm_api/related_target_lifecycle.test.ts +++ b/src/app/service/content/gm_api/related_target_lifecycle.test.ts @@ -50,19 +50,19 @@ describe("relatedTarget lifecycle across content runtime callers", () => { const parent = document.createElement("section"); try { - const style = api.GM_addStyle("body { color: red; }"); + const style = api.GM_addStyle(api, "body { color: red; }"); expect(style?.tagName).toBe("STYLE"); expect(style?.textContent).toBe("body { color: red; }"); expect(sender.relatedTarget).toHaveProperty("size", 0); expect(receiver.relatedTarget).toHaveProperty("size", 0); - const child = api.GM_addElement(parent, "span", { id: "child" }); + const child = api.GM_addElement(api, parent, "span", { id: "child" }); expect(child?.parentNode).toBe(parent); expect(child?.id).toBe("child"); expect(sender.relatedTarget).toHaveProperty("size", 0); expect(receiver.relatedTarget).toHaveProperty("size", 0); - const root = api.GM_addElement("div", { id: "root" }); + const root = api.GM_addElement(api, "div", { id: "root" }); expect(root?.tagName).toBe("DIV"); expect(root?.id).toBe("root"); expect(sender.relatedTarget).toHaveProperty("size", 0); diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 0f50887a5..61d0f060f 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -38,8 +38,6 @@ export interface ApiParam { follow?: string; depend?: string[]; alias?: string; - /** API receives its GM context as the first argument instead of via `this`. */ - bind?: boolean; } export interface ApiValue { diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index e1840b447..ef407a2c9 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -146,7 +146,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, onload: (res) => { resolve(true); @@ -169,7 +169,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, url: testUrl, onload: (res) => { @@ -209,7 +209,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, responseType: "blob", onload: (res) => { @@ -250,7 +250,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, responseType: "blob", url: "https://mock-xmlhttprequest.test/", @@ -288,7 +288,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, responseType: "json", onload: (res) => { @@ -319,7 +319,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, url: testUrl, responseType: "json", @@ -346,7 +346,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("get", () => { return new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com", onreadystatechange: (resp) => { if (resp.readyState === 4 && resp.status === 200) { @@ -361,7 +361,7 @@ describe.concurrent("GM xmlHttpRequest", () => { // xml原版是没有responseText的,但是tampermonkey有,恶心的兼容性 it.concurrent("json", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://example.com/json", method: "GET", responseType: "json", @@ -375,7 +375,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); // bad json await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/", method: "GET", responseType: "json", @@ -389,7 +389,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("header", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/header", method: "GET", headers: { @@ -409,7 +409,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("404", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/notexist", method: "GET", onload: (resp) => { @@ -441,7 +441,7 @@ describe("GM download", () => { const onprogress = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_download({ + gmApi.GM_download(gmApi, { url: "https://download.test/", name: "example.txt", onprogress: onprogress, From f13f7e2f732f62a6153db962477d7930092db83a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:00:03 +0900 Subject: [PATCH 016/106] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20reduce=20runtime?= =?UTF-8?q?=20function=20overhead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.ts | 15 ++--- src/app/service/content/create_context.ts | 23 ++++++- src/app/service/content/gm_api/cat_agent.ts | 32 ++++------ .../service/content/gm_api/cat_agent_task.ts | 23 +++---- .../service/content/script_executor.test.ts | 11 ++-- src/app/service/content/script_executor.ts | 60 +++++-------------- src/app/service/content/scripting.ts | 2 +- src/app/service/service_worker/runtime.ts | 25 ++++---- 8 files changed, 87 insertions(+), 104 deletions(-) diff --git a/packages/message/server.ts b/packages/message/server.ts index 45612d1c1..225fbbc56 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -370,12 +370,13 @@ export function forwardMessage( } return handler(params, sender); }; - const process = (params: any, sender: IGetSender) => { - if (!transform) return processTransformed(params, sender); - const transformed = transform(params, sender); - return transformed instanceof Promise - ? transformed.then((data) => processTransformed(data, sender)) - : processTransformed(transformed, sender); - }; + const process = transform + ? (params: any, sender: IGetSender) => { + const transformed = transform(params, sender); + return transformed instanceof Promise + ? transformed.then((data) => processTransformed(data, sender)) + : processTransformed(transformed, sender); + } + : processTransformed; receiverFrom.on(path, process); } diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 22c3960e9..380320bd5 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -12,7 +12,28 @@ import { nativeCall, Native } from "./global"; const createCapability = (api: (...args: any[]) => any, receiver: object) => { // 由闭包提供上下文,脚本侧只传 API 自身的参数。 - const capability = (...args: any[]) => api(receiver, ...args); + /* eslint-disable prefer-rest-params -- 以固定参数转发保留调用参数数量,避免每次调用创建 rest 数组。 */ + const capability = function (this: unknown) { + switch (arguments.length) { + case 0: + return api(receiver); + case 1: + return api(receiver, arguments[0]); + case 2: + return api(receiver, arguments[0], arguments[1]); + case 3: + return api(receiver, arguments[0], arguments[1], arguments[2]); + case 4: + return api(receiver, arguments[0], arguments[1], arguments[2], arguments[3]); + default: { + const args = new Array(arguments.length + 1); + args[0] = receiver; + for (let i = 0; i < arguments.length; i += 1) args[i + 1] = arguments[i]; + return Native.reflectApply(api, undefined, args); + } + } + }; + /* eslint-enable prefer-rest-params */ Native.objectDefineProperty(capability, "name", { configurable: true, value: api.name, diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index e32f911c8..b18cfc711 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -93,15 +93,9 @@ type ConversationPrivateState = { background: boolean; }; -const conversationStates = Native.createWeakMap(); - -const getConversationState = (instance: ConversationInstance): ConversationPrivateState => { - const state = conversationStates.get(instance); - if (!state) throw new Error("conversation instance is invalid"); - return state; -}; - export class ConversationInstance { + #state: ConversationPrivateState; + public toolHandlers: Map = new Map(); public toolDefs: ToolDefinition[] = []; public ephemeral: boolean; @@ -134,7 +128,7 @@ export class ConversationInstance { systemPrompt: system, background: background || false, }; - conversationStates.set(this, state); + this.#state = state; this.ephemeral = ephemeral || false; if (initialTools) { for (const tool of initialTools) { @@ -158,15 +152,15 @@ export class ConversationInstance { } get id() { - return getConversationState(this).conv.id; + return this.#state.conv.id; } get title() { - return getConversationState(this).conv.title; + return this.#state.conv.title; } get modelId() { - return getConversationState(this).conv.modelId; + return this.#state.conv.modelId; } // 发送消息并获取回复(内置 tool calling 循环) @@ -177,7 +171,7 @@ export class ConversationInstance { if (cmdResult !== undefined) return cmdResult; const { toolDefs, handlers } = this.mergeTools(options?.tools); - const state = getConversationState(this); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 if (this.ephemeral) { @@ -245,7 +239,7 @@ export class ConversationInstance { } const { toolDefs, handlers } = this.mergeTools(options?.tools); - const state = getConversationState(this); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 if (this.ephemeral) { @@ -299,7 +293,7 @@ export class ConversationInstance { const parsed = this.parseCommand(content); if (!parsed) return undefined; - const handler = getConversationState(this).commandHandlers.get(parsed.name); + const handler = this.#state.commandHandlers.get(parsed.name); if (!handler) return undefined; const result = await handler(parsed.args, this); @@ -327,7 +321,7 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { - const state = getConversationState(this); + const state = this.#state; if (this.ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 return this.messageHistory.map((msg, idx) => ({ @@ -357,7 +351,7 @@ export class ConversationInstance { this.messageHistory = []; return; } - const state = getConversationState(this); + const state = this.#state; await state.gmSendMessage("CAT_agentConversation", [ { action: "clearMessages", @@ -370,7 +364,7 @@ export class ConversationInstance { // 持久化对话 async save(): Promise { - const state = getConversationState(this); + const state = this.#state; await state.gmSendMessage("CAT_agentConversation", [ { action: "save", @@ -383,7 +377,7 @@ export class ConversationInstance { // 附加到后台运行中的会话,返回流式事件(首个 chunk 为 sync 快照) async attach(): Promise> { - const state = getConversationState(this); + const state = this.#state; const conn = await state.gmConnect("CAT_agentAttachToConversation", [ { conversationId: state.conv.id, generation: state.conv.generation, scriptUuid: state.scriptUuid }, ]); diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index 3ef602884..f13432461 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -19,12 +19,12 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; -const listenerMaps = Native.createWeakMap(); +const listenerMaps = Native.createWeakMap>(); -const getListenerRecords = (owner: object): ListenerRecord[] => { +const getListenerRecords = (owner: object): Map => { let records = listenerMaps.get(owner); if (!records) { - records = []; + records = Native.createMap(); listenerMaps.set(owner, records); } return records; @@ -124,8 +124,7 @@ export default class CATAgentTaskApi { }; ctx.EE.on(eventName, wrappedCallback); - const records = getListenerRecords(ctx); - records[records.length] = { id: listenerId, eventName, callback: wrappedCallback }; + getListenerRecords(ctx).set(listenerId, { id: listenerId, eventName, callback: wrappedCallback }); return listenerId; } @@ -135,17 +134,9 @@ export default class CATAgentTaskApi { if (!ctx.EE) return; const records = getListenerRecords(ctx); - let index = -1; - for (let i = 0; i < records.length; i += 1) { - if (records[i]?.id === listenerId) { - index = i; - break; - } - } - if (index >= 0) { - const entry = records[index]; - for (let i = index + 1; i < records.length; i += 1) records[i - 1] = records[i]; - records.length -= 1; + const entry = records.get(listenerId); + if (entry) { + records.delete(listenerId); ctx.EE.off(entry.eventName, entry.callback); } } diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 8c1816cff..969243ab1 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -97,14 +97,15 @@ describe("ScriptExecutor", () => { const exec = ( executor as unknown as { - execScripts: Array<{ - exec: { + execScripts: Map< + string, + { scriptRes: TScriptInfo; updateEarlyScriptGMInfo: (envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) => void; - }; - }>; + } + >; } - ).execScripts[0].exec; + ).execScripts.get(initial.uuid)!; expect(exec.scriptRes.executionHandle).toBeUndefined(); exec.updateEarlyScriptGMInfo(initEnvInfo, { diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 3e8d62a2c..1cea9add0 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -32,8 +32,8 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - private readonly earlyScriptFlags: string[] = []; - private readonly execScripts: Array<{ uuid: string; exec: ExecScript }> = []; + private readonly earlyScriptFlags = Native.createSet(); + private readonly execScripts = Native.createMap(); constructor( private msg: Message, @@ -43,24 +43,17 @@ export class ScriptExecutor { emitEvent(data: EmitEventRequest) { // 转发给脚本 - for (let i = 0; i < this.execScripts.length; i += 1) { - const entry = this.execScripts[i]; - if (entry?.uuid === data.uuid) { - entry.exec.emitEvent(data.event, data.eventId, data.data); - return; - } - } + this.execScripts.get(data.uuid)?.emitEvent(data.event, data.eventId, data.data); } valueUpdate(data: ValueUpdateDataEncoded) { // runtime/valueUpdate const { uuid, storageName } = data; - for (let i = 0; i < this.execScripts.length; i += 1) { - const exec = this.execScripts[i]?.exec; - if (exec && (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName)) { + this.execScripts.forEach((exec) => { + if (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName) { exec.valueUpdate(data); } - } + }); } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { @@ -78,22 +71,16 @@ export class ScriptExecutor { const script = scripts[scriptIndex]; const flag = script.flag; // 如果是EarlyScriptFlag,处理沙盒环境 - let isEarlyScript = false; - for (let i = 0; i < this.earlyScriptFlags.length; i += 1) { - if (this.earlyScriptFlags[i] === flag) { - isEarlyScript = true; - break; - } - } - if (isEarlyScript) { - for (let i = 0; i < this.execScripts.length; i += 1) { - const exec = this.execScripts[i]?.exec; - if (exec?.scriptRes.flag === flag) { + if (this.earlyScriptFlags.has(flag)) { + let updated = false; + this.execScripts.forEach((exec) => { + if (!updated && exec.scriptRes.flag === flag) { // 处理早期脚本的沙盒环境 exec.updateEarlyScriptGMInfo(envInfo, script); - return; + updated = true; } - } + }); + if (updated) return; } const listenForScript = () => { definePropertyListener(window, flag, (val: ScriptFunc) => { @@ -153,14 +140,7 @@ export class ScriptExecutor { console.warn("Unexpected match error", e); } } - let alreadyExecuted = false; - for (let i = 0; i < this.earlyScriptFlags.length; i += 1) { - if (this.earlyScriptFlags[i] === scriptFlag) { - alreadyExecuted = true; - break; - } - } - if (!alreadyExecuted) this.execEarlyScript(scriptFlag, scriptInfo, envInfo); + if (!this.earlyScriptFlags.has(scriptFlag)) this.execEarlyScript(scriptFlag, scriptInfo, envInfo); } }; pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); @@ -190,7 +170,7 @@ export class ScriptExecutor { scriptFlag: flag, envInfo: envInfo, }); - this.earlyScriptFlags[this.earlyScriptFlags.length] = flag; + this.earlyScriptFlags.add(flag); } execScriptEntry(scriptEntry: ExecScriptEntry) { @@ -205,15 +185,7 @@ export class ScriptExecutor { code: scriptFunc, envInfo, }); - let replaced = false; - for (let i = 0; i < this.execScripts.length; i += 1) { - if (this.execScripts[i]?.uuid === scriptLoadInfo.uuid) { - this.execScripts[i] = { uuid: scriptLoadInfo.uuid, exec: execScript }; - replaced = true; - break; - } - } - if (!replaced) this.execScripts[this.execScripts.length] = { uuid: scriptLoadInfo.uuid, exec: execScript }; + this.execScripts.set(scriptLoadInfo.uuid, execScript); const metadata = scriptLoadInfo.metadata || {}; const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index b465ac641..419d899cc 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -148,7 +148,7 @@ export default class ScriptingRuntime { return { uuid: request.uuid, api: request.api, - params: [...request.params], + params: request.params, runFlag: request.runFlag, executionHandle: request.handle, version: 1 as const, diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 3e1bc47ac..3e773c6b8 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -300,6 +300,10 @@ export class RuntimeService { } private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { + const dataRecord = + typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; + const targetUuid = action === "runtime/emitEvent" ? dataRecord?.uuid : undefined; + const targetStorageName = action === "runtime/valueUpdate" ? dataRecord?.storageName : undefined; for (const [key, entry] of this.userScriptConnections) { if ( to && @@ -309,18 +313,17 @@ export class RuntimeService { ) { continue; } - const bindingMatches = [...this.pageExecutionBindings.values()].some( - (binding) => + let bindingMatches = false; + for (const binding of this.pageExecutionBindings.values()) { + if ( entry.handles.has(binding.handle) && - ((action === "runtime/emitEvent" && - typeof data === "object" && - data !== null && - (data as { uuid?: unknown }).uuid === binding.uuid) || - (action === "runtime/valueUpdate" && - typeof data === "object" && - data !== null && - (data as { storageName?: unknown }).storageName === binding.storageName)) - ); + ((targetUuid !== undefined && targetUuid === binding.uuid) || + (targetStorageName !== undefined && targetStorageName === binding.storageName)) + ) { + bindingMatches = true; + break; + } + } if (!bindingMatches) continue; try { entry.connection.sendMessage({ action: `content/${action}`, data }); From edc3ab3c5e9fbf440fb19e5571ab6f0a616f80d0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:10:58 +0900 Subject: [PATCH 017/106] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20remove=20redundant?= =?UTF-8?q?=20native=20collection=20factories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 6 +- src/app/service/content/create_context.ts | 8 +-- src/app/service/content/exec_script.ts | 2 +- src/app/service/content/global.ts | 60 ++++--------------- .../service/content/gm_api/cat_agent_task.ts | 4 +- src/app/service/content/gm_api/gm_api.ts | 6 +- src/app/service/content/gm_api/gm_xhr.ts | 4 +- src/app/service/content/script_executor.ts | 4 +- 8 files changed, 29 insertions(+), 65 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 717d5d62a..13495e87a 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -307,9 +307,9 @@ describe("shouldFnBind", () => { describe("createContext: capability and lifecycle contract", () => { it("creates collection instances from frozen captured-method subclasses", () => { - const set = Native.createSet(["grant"]); - const map = Native.createMap(); - const weakMap = Native.createWeakMap(); + const set = new Native.Set(["grant"]); + const map = new Native.Map(); + const weakMap = new Native.WeakMap(); expect(set).toBeInstanceOf(Native.Set); expect(map).toBeInstanceOf(Native.Map); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 380320bd5..cd280b3c7 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -53,7 +53,7 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { - const scriptGrantSet = Native.createSet(scriptGrants); + const scriptGrantSet = new Native.Set(scriptGrants); // 按照GMApi构建 const valueChangeListener = new ListenerManager(); const EE = new EventEmitter(); @@ -80,7 +80,7 @@ export const createContext = ( GM: GM, GM_info: GMInfo, window: Native.objectCreate(null), - grantSet: Native.createSet(), + grantSet: new Native.Set(), loadScriptPromise, loadScriptResolve, setInvalidContext() { @@ -246,7 +246,7 @@ export type RealmRoots = { const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 // 先处理的 descriptor 覆盖后续父类。 - const descsCache: Set = Native.createSet(["eval", "window", "self", "globalThis", "top", "parent"]); + const descsCache: Set = new Native.Set(["eval", "window", "self", "globalThis", "top", "parent"]); // realmGlobal own descriptor 优先,hostWindow descriptor 只补足 host 成员。 const initOwnDescs = Native.objectGetOwnPropertyDescriptors(realmGlobal); @@ -256,7 +256,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const overriddenDescs: DescriptorMap = Native.objectCreate(null); // 记录原生 onxxxxx 的 property key。 - const eventKeys = Native.createSet(); + const eventKeys = new Native.Set(); // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 const protoBaseDescs: DescriptorMap = Native.objectCreate(null); diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index bd805ec1d..3213398a7 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -51,7 +51,7 @@ export default class ExecScript { } else { this.scriptFunc = code; } - const grantSet = Native.createSet(scriptRes.metadata.grant || []); + const grantSet = new Native.Set(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { grantSet.add("GM_registerMenuCommand"); grantSet.delete("none"); diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 9b43fcc10..6c94d9e7c 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -29,7 +29,18 @@ const nativeObjectFreeze = Object.freeze; // Keep the captured methods on private subclasses. Instances can then be created // without reassigning every method, while the subclass prototypes remain outside // the page's mutable built-in prototypes. -const NativeSetConstructor = class extends nativeSetConstructor {}; +const NativeSetConstructor = class extends nativeSetConstructor { + constructor(values?: readonly T[] | Set | null) { + super(); + if (Array.isArray(values)) { + for (let i = 0; i < values.length; i += 1) { + nativeReflectApply(nativeSetAdd, this, [values[i]]); + } + } else if (values) { + nativeReflectApply(nativeSetForEach, values, [(value: T) => nativeReflectApply(nativeSetAdd, this, [value])]); + } + } +}; NativeSetConstructor.prototype.add = nativeSetAdd; NativeSetConstructor.prototype.has = nativeSetHas; NativeSetConstructor.prototype.delete = nativeSetDelete; @@ -65,54 +76,10 @@ export const nativeCall = nativeFunctionCall; export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => nativeFunctionCall(nativeFunctionBind, fn, receiver, ...args); -type SafeSet = Set & { - add: Set["add"]; - has: Set["has"]; - delete: Set["delete"]; - clear: Set["clear"]; - forEach: Set["forEach"]; -}; - -type SafeMap = Map & { - get: Map["get"]; - set: Map["set"]; - has: Map["has"]; - delete: Map["delete"]; - clear: Map["clear"]; - forEach: Map["forEach"]; -}; - -type SafeWeakMap = WeakMap & { - get: WeakMap["get"]; - set: WeakMap["set"]; - has: WeakMap["has"]; - delete: WeakMap["delete"]; -}; - -const createNativeSet = (values?: readonly T[] | Set | null): SafeSet => { - const set = new NativeSetConstructor() as SafeSet; - if (Array.isArray(values)) { - for (let i = 0; i < values.length; i += 1) set.add(values[i]); - } else if (values) { - nativeReflectApply(nativeSetForEach, values, [(value: T) => set.add(value)]); - } - return set; -}; - -const createNativeMap = (): SafeMap => { - return new NativeMapConstructor() as SafeMap; -}; - -const createNativeWeakMap = (): SafeWeakMap => { - return new NativeWeakMapConstructor() as SafeWeakMap; -}; - export const Native = { Set: NativeSetConstructor, Map: NativeMapConstructor, WeakMap: NativeWeakMapConstructor, - apply: nativeApply, - call: nativeCall, bind: nativeBind, reflectApply: nativeReflectApply, structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, @@ -130,9 +97,6 @@ export const Native = { objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), reflectGet: nativeBind(Reflect.get, Reflect), - createSet: createNativeSet, - createMap: createNativeMap, - createWeakMap: createNativeWeakMap, } as const; export const customClone = (o: any) => { diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index f13432461..6002c4a9e 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -19,12 +19,12 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; -const listenerMaps = Native.createWeakMap>(); +const listenerMaps = new Native.WeakMap>(); const getListenerRecords = (owner: object): Map => { let records = listenerMaps.get(owner); if (!records) { - records = Native.createMap(); + records = new Native.Map(); listenerMaps.set(owner, records); } return records; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index a1d47cc09..804609c30 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -62,12 +62,12 @@ let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; const valueChangePromiseMap: Record void> = Object.create(null); -const notificationTagMaps = Native.createWeakMap>(); +const notificationTagMaps = new Native.WeakMap>(); const getNotificationTagMap = (owner: object): Map => { let map = notificationTagMaps.get(owner); if (!map) { - map = Native.createMap(); + map = new Native.Map(); notificationTagMaps.set(owner, map); } return map; @@ -76,7 +76,7 @@ const getNotificationTagMap = (owner: object): Map => { const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 - execEnv.menuKeyRegistered = Native.createSet(); + execEnv.menuKeyRegistered = new Native.Set(); execEnv.menuIdCounter = 0; execEnv.regMenuCounter = 0; } diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 28d00a620..17ec61fae 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -127,7 +127,7 @@ const getMimeType = (contentType: string) => { return mime; }; -const docParseTypes = Native.createSet([ +const docParseTypes = new Native.Set([ "application/xhtml+xml", "application/xml", "image/svg+xml", @@ -135,7 +135,7 @@ const docParseTypes = Native.createSet([ "text/xml", ]); -const retStateFnMap = Native.createWeakMap(); +const retStateFnMap = new Native.WeakMap(); const invokeXHRCallback = (name: string, callback: ((value: any) => void) | undefined, value: any) => { if (!callback) return; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 1cea9add0..0e3892acc 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -32,8 +32,8 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - private readonly earlyScriptFlags = Native.createSet(); - private readonly execScripts = Native.createMap(); + private readonly earlyScriptFlags = new Native.Set(); + private readonly execScripts = new Native.Map(); constructor( private msg: Message, From b5007dfad4c659387ae675df5782b7cb0f6e536b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:31:20 +0900 Subject: [PATCH 018/106] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20simplify=20native?= =?UTF-8?q?=20set=20initialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 6c94d9e7c..1e4b56b67 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -33,11 +33,9 @@ const NativeSetConstructor = class extends nativeSetConstructor { constructor(values?: readonly T[] | Set | null) { super(); if (Array.isArray(values)) { - for (let i = 0; i < values.length; i += 1) { - nativeReflectApply(nativeSetAdd, this, [values[i]]); - } + for (let i = 0; i < values.length; i += 1) this.add(values[i]); } else if (values) { - nativeReflectApply(nativeSetForEach, values, [(value: T) => nativeReflectApply(nativeSetAdd, this, [value])]); + nativeReflectApply(nativeSetForEach, values, [(value: T) => this.add(value)]); } } }; From 4d194e11c5b4e0ffc7e3077ab41c8cfdfe28184a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:43:45 +0900 Subject: [PATCH 019/106] =?UTF-8?q?=F0=9F=93=84=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=B7=A8=E4=B8=96=E7=95=8C=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明原生方法捕获、USER_SCRIPT 来源校验、页面 RPC 句柄生命周期、脚本包装完整性和回调收尾约束,降低后续维护时误改安全契约的风险。 --- packages/message/extension_message.ts | 4 ++++ packages/message/server.ts | 3 +++ rspack.config.ts | 1 + src/app/repo/scripts.ts | 2 +- src/app/service/content/create_context.ts | 3 +++ src/app/service/content/exec_script.ts | 2 ++ src/app/service/content/global.ts | 1 + src/app/service/content/gm_api/cat_agent.ts | 1 + src/app/service/content/gm_api/cat_agent_task.ts | 2 ++ src/app/service/content/gm_api/gm_api.ts | 12 +++++++++--- src/app/service/content/gm_api/gm_xhr.ts | 7 +++---- src/app/service/content/listener_manager.ts | 1 + src/app/service/content/page_rpc.ts | 9 +++++++-- src/app/service/content/script_executor.ts | 3 +++ src/app/service/content/script_runtime.ts | 1 + src/app/service/content/scripting.ts | 9 +++++++-- src/app/service/content/utils.ts | 8 +++++++- src/app/service/service_worker/client.ts | 1 + src/app/service/service_worker/runtime.ts | 13 +++++++++---- src/app/service/service_worker/types.ts | 2 +- src/content.ts | 4 ++-- 21 files changed, 69 insertions(+), 20 deletions(-) diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 1e519470a..2fcd610a6 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -11,6 +11,8 @@ import type { import { uuidv4 } from "@App/pkg/utils/uuid"; const listenerMgr = new EventEmitter(); // 单一管理器 +// 这些引用必须在页面或 USER_SCRIPT 世界有机会改写 chrome.runtime 方法前捕获, +// 否则消息边界会再次查找页面可变的属性。 const runtimeApi = typeof chrome === "undefined" ? undefined : chrome.runtime; const nativeRuntimeConnect = typeof runtimeApi?.connect === "function" ? runtimeApi.connect.bind(runtimeApi) : undefined; @@ -192,10 +194,12 @@ export class ExtensionMessageConnect implements MessageConnect { constructor( con: chrome.runtime.Port, + // 来源只记录浏览器原生通道的来源,供服务端区分 USER_SCRIPT 与扩展内部消息。 private readonly origin: "extension" | "userScript" = "extension" ) { this.con = con; // 强引用 if (typeof con.postMessage !== "function") throw new TypeError("Invalid runtime port"); + // Port 的原型可能被页面改写;后续发送固定使用构造时取得的绑定方法。 this.postMessage = con.postMessage.bind(con); const handler = (msg: TMessage, _con: chrome.runtime.Port) => { listenerMgr.emit(`onMessage:${this.listenerId}`, msg); diff --git a/packages/message/server.ts b/packages/message/server.ts index 225fbbc56..71f2f8415 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -14,6 +14,7 @@ import Logger from "@App/app/logger/logger"; const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; +// 转发监听器会跨 context 保存一段时间,绑定时固定原生 bind,避免页面改写原型。 const bindNative = any>(fn: T, receiver: any): T => nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; @@ -268,6 +269,7 @@ export class Server { } private isUserScriptActionAllowed(action: string, origin: MessageOrigin | undefined, isConnect: boolean): boolean { + // USER_SCRIPT 只应取得注册握手和 GM RPC;其他 serviceWorker API 仍只接受扩展通道。 if (this.prefix !== "serviceWorker" || origin !== "userScript") return true; return isConnect ? action === "runtime/registerUserScript" || action === "runtime/gmApi" @@ -372,6 +374,7 @@ export function forwardMessage( }; const process = transform ? (params: any, sender: IGetSender) => { + // 转换先于中间件和转发执行,使跨世界输入只在一个受控位置完成校验/复制。 const transformed = transform(params, sender); return transformed instanceof Promise ? transformed.then((data) => processTransformed(data, sender)) diff --git a/rspack.config.ts b/rspack.config.ts index 35d02137b..3848a05aa 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,6 +138,7 @@ export default { new rspack.DefinePlugin({ "process.env.VI_TESTING": "'false'", "process.env.SC_RANDOM_KEY": `'${uuidv4()}'`, + // 每次构建都生成独立标记,脚本包装器只接受扩展内部传入的完整性密钥。 "process.env.SC_RANDOM_FNKEY": `'${uuidv4()}'`, "process.env.SC_ZN_RAND": `'$${uuidv4()}'`, "process.env.SC_DISABLE_AGENT": `'${enableAgent ? "false" : "true"}'`, diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index 285de420a..27cdfbf24 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -158,7 +158,7 @@ export type TClientPageLoadInfo = injectScriptList: TScriptInfo[]; contentScriptList: TScriptInfo[]; envInfo: GMInfoEnv; - /** One-use token that lets the USER_SCRIPT world request its private bootstrap. */ + /** 一次性令牌,供 USER_SCRIPT world 请求私有 bootstrap。 */ userScriptBootstrapToken?: string; } | { ok: false }; diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index cd280b3c7..990d3da3d 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -53,6 +53,7 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { + // 复制授权集合并使用捕获的 Set 实现,避免页面改写迭代器后影响 API 注入。 const scriptGrantSet = new Native.Set(scriptGrants); // 按照GMApi构建 const valueChangeListener = new ListenerManager(); @@ -116,6 +117,7 @@ export const createContext = ( } return true; }; + // 只能调用捕获的 forEach;此处不依赖页面提供的 Set iterator。 scriptGrantSet.forEach((grant) => { const candidates = getGrantCandidates(String(grant)); for (let i = 0; i < candidates.length; i += 1) { @@ -450,6 +452,7 @@ export const createProxyContext = ( }; }; + // 事件键只需传入沙盒属性;先用捕获的 forEach 转成数组,避免跨 realm 读取 iterator。 const eventKeyList: string[] = []; eventKeys.forEach((key) => { eventKeyList[eventKeyList.length] = String(key); diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 3213398a7..dedcce66d 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -10,6 +10,7 @@ import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; import { Native } from "./global"; +// 编译函数只在收到本次构建的密钥时执行,避免页面直接复用包装器。 const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; // 执行脚本,控制脚本执行与停止 @@ -97,6 +98,7 @@ export default class ExecScript { // 早期启动的脚本,处理GM API updateEarlyScriptGMInfo(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) { if (scriptInfo?.executionHandle && scriptInfo.executionEnvTag) { + // early-start 先执行后取得绑定;此处补写同一绑定,使后续 RPC 与首次注册一致。 this.scriptRes.executionHandle = scriptInfo.executionHandle; this.scriptRes.executionEnvTag = scriptInfo.executionEnvTag; this.scriptRes.executionRunFlag = scriptInfo.executionRunFlag; diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 1e4b56b67..8e7702946 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -31,6 +31,7 @@ const nativeObjectFreeze = Object.freeze; // the page's mutable built-in prototypes. const NativeSetConstructor = class extends nativeSetConstructor { constructor(values?: readonly T[] | Set | null) { + // 不把 values 传给 Set 构造器:它会读取 values 的 @@iterator,而页面可改写该方法。 super(); if (Array.isArray(values)) { for (let i = 0; i < values.length; i += 1) this.add(values[i]); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index b18cfc711..06126d5cd 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -94,6 +94,7 @@ type ConversationPrivateState = { }; export class ConversationInstance { + // 私有状态包含跨 context 的发送函数;用 private field 隐藏它,避免脚本读取或替换传输入口。 #state: ConversationPrivateState; public toolHandlers: Map = new Map(); diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index 6002c4a9e..4a0d9cd73 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -20,6 +20,7 @@ interface GMBaseContext { let listenerCounter = 0; type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; const listenerMaps = new Native.WeakMap>(); +// 监听记录按 GM context 隔离;WeakMap 让脚本结束后不会因监听表反向持有 context。 const getListenerRecords = (owner: object): Map => { let records = listenerMaps.get(owner); @@ -136,6 +137,7 @@ export default class CATAgentTaskApi { const records = getListenerRecords(ctx); const entry = records.get(listenerId); if (entry) { + // 记录事件名和包装回调后可直接移除,不必扫描所有任务监听器。 records.delete(listenerId); ctx.EE.off(entry.eventName, entry.callback); } diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 804609c30..95e312659 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -60,8 +60,10 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; +// 回调表不暴露 Map 原型,避免页面改写 Map 方法后影响值更新确认。 const valueChangePromiseMap: Record void> = Object.create(null); +// 通知 ID 只属于对应 GM context;WeakMap 不让脚本结束后残留监听状态。 const notificationTagMaps = new Native.WeakMap>(); const getNotificationTagMap = (owner: object): Map => { @@ -148,9 +150,8 @@ class GM_Base implements IGM_Base { if (this.loadScriptPromise) { await this.loadScriptPromise; } - // USER_SCRIPT has DOM and fetch access in its own realm. Keep these helper - // operations local instead of sending an internal CAT operation to the SW, - // where only the isolated scripting broker has an implementation. + // USER_SCRIPT 自己的 realm 已有 DOM 与 fetch;这些辅助操作必须留在本地, + // 不能改走只有隔离 broker 才实现的内部 CAT service worker 请求。 if (this.scriptRes.executionEnvTag === ScriptEnvTag.content) { if (api === "CAT_fetchBlob") { if (!isExtensionBlobUrl(params[0])) throw new Error("CAT_fetchBlob expects an extension blob URL"); @@ -163,6 +164,7 @@ class GM_Base implements IGM_Base { } let ret; try { + // 有页面句柄时走版本化 RPC;后台脚本和未迁移上下文继续使用旧请求形状。 const request = this.scriptRes.executionHandle ? { version: 1 as const, @@ -198,6 +200,7 @@ class GM_Base implements IGM_Base { await this.loadScriptPromise; } if (!this.message || !this.scriptRes) return new Promise(() => {}); + // 长连接也必须携带同一页面句柄,否则 broker 无法把连接绑定回脚本和文档。 const request = this.scriptRes.executionHandle ? { version: 1 as const, @@ -244,6 +247,7 @@ class GM_Base implements IGM_Base { } else { valueStore[key] = value; } + // 监听器属于脚本,传副本避免回调修改 GM 存储或跨 context 共享对象。 const listenerValue = value && typeof value === "object" ? customClone(value) : value; const listenerOldValue = oldValue && typeof oldValue === "object" ? customClone(oldValue) : oldValue; this.valueChangeListener.execute(key, listenerOldValue, listenerValue, remote, sender.tabId); @@ -255,6 +259,7 @@ class GM_Base implements IGM_Base { @GMContext.protected() emitEvent(event: string, eventId: string, data: any) { if (!this.EE) return; + // 事件回调同样不能拿到 broker 内部对象的可变引用。 const callbackData = data && typeof data === "object" ? customClone(data) : data; this.EE.emit(`${event}:${eventId}`, callbackData); } @@ -597,6 +602,7 @@ export default class GMApi extends GM_Base { if (ctx.isInvalidContext()) return undefined; if (ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content) { + // USER_SCRIPT 可直接在 content realm 创建 Document;跨到 scripting 只会丢失节点引用。 return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 17ec61fae..96eb9f23f 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -142,8 +142,7 @@ const invokeXHRCallback = (name: string, callback: ((value: any) => void) | unde try { callback(value); } catch (error) { - // User callback failures are reported without rejecting the internal - // message queue or interrupting request settlement. + // 用户回调异常只记录,不得拒绝内部消息队列或打断请求收尾。 LoggerCore.logger().error("GM_xmlhttpRequest callback failed", { name, ...Logger.E(error) }); } }; @@ -537,6 +536,7 @@ export function GM_xmlhttpRequest( } }; const scheduleSyntheticLoadEnd = () => { + // abort/error/timeout 可能没有 broker 的 onloadend,补发一次以释放连接和引用。 Promise.resolve({ error: "loadend", responseHeaders: "", @@ -549,8 +549,7 @@ export function GM_xmlhttpRequest( if (!reqDone) { errorOccur = "AbortError"; reqDone = true; - // Mark the request settled before user code runs. A throwing abort - // callback must not leave the broker connection and loadend cleanup pending. + // 先标记完成再调用用户代码;回调抛错也不能留下未收尾的连接。 invokeXHRCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); diff --git a/src/app/service/content/listener_manager.ts b/src/app/service/content/listener_manager.ts index 39f180dda..ef9aa23ed 100644 --- a/src/app/service/content/listener_manager.ts +++ b/src/app/service/content/listener_manager.ts @@ -11,6 +11,7 @@ export class ListenerManager void> { } public execute(key: string, ...args: T extends (key: string, ...a: infer A) => any ? A : never): void { + // handler 可能在执行期间移除自身;按当前下标复查 id,避免跳过紧邻监听器。 for (let i = 0; i < this.listeners.length; ) { const listener = this.listeners[i]; if (listener?.key !== key) { diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 74337dfda..8d9b93403 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -22,6 +22,7 @@ export const getExtensionOrigin = (): ExtensionOrigin | undefined => { return undefined; }; +// USER_SCRIPT 的 blob URL 必须回指当前扩展 origin,origin 由隔离 context 提供并缓存。 let configuredExtensionOrigin: ExtensionOrigin | undefined; export const setPageRpcExtensionOrigin = (value: unknown): void => { @@ -92,7 +93,7 @@ export type PageGMRequest = { readonly runFlag: string; }; -/** The untrusted packet accepted from a MAIN-world script. */ +/** MAIN world 脚本可提交的不可信数据包。 */ export type PageGMRequestPacket = { readonly version: typeof PAGE_RPC_VERSION; readonly requestId: string; @@ -112,7 +113,7 @@ const INTERNAL_APIS_BY_GRANT: Readonly> = { "GM.xmlHttpRequest": ["GM_xmlhttpRequest"], }; -// ScriptingRuntime does not load the GM implementation module, so mirror its small dependency graph here. +// ScriptingRuntime 不加载 GM 实现模块,因此在此镜像一份精简依赖图。 const API_DEPENDENCIES: Readonly> = { "GM.getValues": ["GM_getValues"], "GM.cookie": ["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"], @@ -179,6 +180,7 @@ const ownData = (value: object, key: PropertyKey): unknown => { }; const assertDataOnly = (value: unknown, seen: Set): void => { + // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 if (value === null || typeof value !== "object") return; if (seen.has(value)) return; seen.add(value); @@ -197,6 +199,7 @@ const assertDataOnly = (value: unknown, seen: Set): void => { }; const cloneParams = (params: unknown): readonly unknown[] => { + // 复制发生在交给 service worker 之前,后续 broker 只处理隔离后的普通值。 if (!Array.isArray(params)) throw new PageRpcError("page RPC params must be an array"); assertDataOnly(params, new Set()); if (!nativeStructuredClone) throw new PageRpcError("structured clone is unavailable"); @@ -281,6 +284,7 @@ export class PageRpcRegistry { } consumeRequestId(binding: PageExecutionBinding, requestId: string): void { + // requestId 只在每个绑定内去重,并保留有限窗口,避免页面长期占用内存。 if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); binding.requestIds.add(requestId); while (binding.requestIds.size > MAX_REQUEST_IDS_PER_BINDING) { @@ -324,6 +328,7 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) } const binding = registry.resolve(handle, api); + // resolve 同时执行句柄、授权和活跃状态检查;不要把页面传来的 api 直接转发给后端。 const clonedParams = cloneParams(params); validateOperationParams(api, clonedParams); registry.consumeRequestId(binding, requestId); diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 0e3892acc..75df76f12 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -11,6 +11,7 @@ import { isUrlExcluded } from "@App/pkg/utils/match"; import type { ScriptEnvTag } from "@Packages/message/consts"; import { localizeObject, Native } from "./global"; +// 与编译器相同的构建级标记,用来拒绝页面伪造的脚本挂载函数。 const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; export type ExecScriptEntry = { @@ -84,6 +85,7 @@ export class ScriptExecutor { } const listenForScript = () => { definePropertyListener(window, flag, (val: ScriptFunc) => { + // 只有扩展生成且不可改写的完整性标记才算有效挂载,页面自建同名函数必须忽略。 const descriptor = typeof val === "function" ? Native.objectGetOwnPropertyDescriptor(val, fnStrIntegrity) : undefined; if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) { @@ -152,6 +154,7 @@ export class ScriptExecutor { execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { const expectedUuid = flag.startsWith("#-") ? flag.slice(2) : undefined; + // early-start 事件来自页面,需同时确认脚本身份和未绑定状态,避免旧事件重放到新文档。 if ( (expectedUuid && scriptInfo.uuid !== expectedUuid) || scriptInfo.executionHandle !== undefined || diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 87fba6f5b..5c76cbfcf 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -25,6 +25,7 @@ export class ScriptRuntime { if (!data || !Array.isArray(data.params) || data.params.length !== 3) return undefined; const [parentNodeId, tagName, tmpAttr] = data.params; + // 此请求来自页面事件,只接受可验证的节点编号、标签名和扁平属性,避免把对象行为带入 DOM 操作。 if ( (parentNodeId !== null && (!Number.isInteger(parentNodeId) || parentNodeId <= 0)) || typeof tagName !== "string" || diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 419d899cc..304fc7a5c 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -25,7 +25,9 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 // scripting页的处理 export default class ScriptingRuntime { + // 只记录当前页面仍有脚本使用的 storageName,storage 广播不应唤醒无关脚本。 private activeStorageNames = new Map(); + // 页面请求必须先在此注册句柄,再由 transform 解析为隔离 broker 可接受的身份。 private readonly pageRpc = new PageRpcRegistry(); constructor( // 监听来自service_worker的消息 @@ -54,11 +56,11 @@ export default class ScriptingRuntime { init() { this.extServer.on("runtime/emitEvent", (data) => { - // USER_SCRIPT receives private callbacks over its native extension port. + // USER_SCRIPT 的私有回调通过原生扩展端口投递。 return this.broadcastToPage("runtime/emitEvent", data, PageOrContent.PAGE); }); this.extServer.on("runtime/valueUpdate", (data) => { - // USER_SCRIPT receives private updates over its native extension port. + // USER_SCRIPT 的私有值更新通过原生扩展端口投递。 return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); }); this.server.on("logger", (data: Logger) => { @@ -144,6 +146,7 @@ export default class ScriptingRuntime { return false; }, (data) => { + // 所有来自页面的 GM RPC 都在转发前完成字段、句柄、授权和参数复制检查。 const request = validatePageGMRequest(data, this.pageRpc); return { uuid: request.uuid, @@ -174,6 +177,7 @@ export default class ScriptingRuntime { client.pageLoad("it").then((o) => { if (!o.ok) return; const { injectScriptList, envInfo, userScriptBootstrapToken } = o; + // 每次页面加载都废弃旧句柄,避免无 documentId 的浏览器复用上一文档的授权。 this.pageRpc.revokeAll(); const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => scripts.map((script) => { @@ -183,6 +187,7 @@ export default class ScriptingRuntime { script.executionHandle || this.pageRpc.register(script.uuid, envTag, allowedAPIs, undefined, executionRunFlag); if (script.executionHandle) { + // service worker 已签发的句柄要在本页 registry 中恢复,保持跨 context 身份一致。 this.pageRpc.register(script.uuid, envTag, allowedAPIs, script.executionHandle, executionRunFlag); } return { ...script, executionHandle, executionEnvTag: envTag, executionRunFlag }; diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 5d9910e54..32f8f6c62 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -13,6 +13,7 @@ const nativeJSONStringify = JSON.stringify.bind(JSON); const nativeJSONParse = JSON.parse.bind(JSON); const cloneTransportValue = (value: any) => { + // USER_SCRIPT 只能接收数据副本;先去掉 Proxy、getter 和原型引用,避免把页面对象带过边界。 if (value === null || typeof value !== "object") return value; if (nativeStructuredClone) { try { @@ -28,6 +29,7 @@ const cloneTransportValue = (value: any) => { } }; +// 与 rspack 注入的构建级密钥配对;页面只能看到包装函数,拿不到正确的调用标记。 const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; @@ -185,10 +187,11 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) } const codeFunction = (code: string) => { - // 临时方法调用不依赖页面改写的 call、apply、bind。 + // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; +// 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 const mountCodeFunction = (flag: string, code: string) => `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code)})`; @@ -286,6 +289,7 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { delete scriptInfo.status; // 脚本状态总是启用 delete scriptInfo.executionHandle; delete scriptInfo.executionEnvTag; + // 这些绑定令牌只在隔离 broker 内有效,不能随脚本资料暴露给页面或 USER_SCRIPT。 delete scriptInfo.executionRunFlag; // --- 处理 scriptInfo --- return scriptInfo; @@ -413,6 +417,7 @@ export function definePropertyListener(obj: any, prop: string, listener: (val if (current !== undefined) { const descriptor = Object.getOwnPropertyDescriptor(obj, prop); listener(current); + // 页面可能在回调里替换属性;只有描述符仍是原来的才可以清理自身监听器。 if (sameProperty(descriptor, Object.getOwnPropertyDescriptor(obj, prop)) && descriptor?.configurable) { delete obj[prop]; } @@ -421,6 +426,7 @@ export function definePropertyListener(obj: any, prop: string, listener: (val const setter = (val: T) => { listener(val); const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + // 不删除页面后来安装的 setter,只删除本函数仍拥有的那一个。 if (descriptor?.configurable && descriptor.set === setter) { delete obj[prop]; } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index c70c1b806..e585c7bf8 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -339,6 +339,7 @@ export class RuntimeClient extends Client { return this.do("stopScript", uuid); } + // envTag 让 service worker 区分主世界请求与 content-world bootstrap,分别签发/回收句柄。 pageLoad(envTag?: "it" | "ct"): Promise { return this.doThrow("pageLoad", envTag ? { envTag } : undefined); } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 3e773c6b8..bb6784168 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -142,7 +142,9 @@ export class RuntimeService { scriptMatchEnable: UrlMatch = new UrlMatch(); blackMatch: UrlMatch = new UrlMatch(); private gmApi?: GMApi; + // 句柄绑定到 tab/frame/document;页面导航、脚本变更或窗口关闭时必须整体撤销。 private readonly pageExecutionBindings = new Map(); + // USER_SCRIPT 连接只保存它获准使用的 content-world 句柄,回调按句柄再做一次归属匹配。 private readonly userScriptConnections = new Map< string, { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } @@ -164,6 +166,7 @@ export class RuntimeService { } private revokePageBindings(sender: IGetSender, envTag?: "it" | "ct"): void { + // documentId 缺失时仍按 tab/frame 退休旧绑定,避免新页面继承上一文档的授权。 const source = sender.getSender(); const tabId = source?.tab?.id; const frameId = source?.frameId; @@ -218,6 +221,7 @@ export class RuntimeService { /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { + // bootstrap token 只允许对应 tab/frame/document 使用一次,并且必须覆盖本次下发的全部句柄。 if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; if (data === null || typeof data !== "object") return false; const handshake = data as { world?: unknown; bootstrapToken?: unknown }; @@ -304,6 +308,7 @@ export class RuntimeService { typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; const targetUuid = action === "runtime/emitEvent" ? dataRecord?.uuid : undefined; const targetStorageName = action === "runtime/valueUpdate" ? dataRecord?.storageName : undefined; + // 先按页面定位,再按句柄对应的脚本或 storageName 过滤,避免跨脚本广播私有回调。 for (const [key, entry] of this.userScriptConnections) { if ( to && @@ -352,6 +357,7 @@ export class RuntimeService { const source = sender.getSender(); const tabId = source?.tab?.id; if (typeof tabId !== "number") throw new Error("page execution binding requires a tab"); + // 每次 pageLoad 都签发新句柄和 runFlag;它们共同绑定当前文档的授权生命周期。 const handle = uuidv4(); const binding = { handle, @@ -717,8 +723,7 @@ export class RuntimeService { sendData, }, }); - // USER_SCRIPT cannot observe the scripting world's page broadcast. Deliver the - // same encoded DTO over its native extension connection instead. + // USER_SCRIPT 看不到 scripting world 的页面广播,改经原生扩展连接投递同一份编码 DTO。 this.sendUserScriptMessage(undefined, "runtime/valueUpdate", sendData); // 後台腳本 @@ -1520,6 +1525,7 @@ export class RuntimeService { } async pageLoad(data: { envTag?: "it" | "ct" } | undefined, sender: IGetSender): Promise { + // USER_SCRIPT 只能通过一次性 bootstrap 获取 content-world 资料,不能自行请求 pageLoad。 if (sender.getConnectOrigin?.() === "userScript") return { ok: false }; const chromeSender = sender.getSender(); const url = chromeSender?.url; @@ -1532,8 +1538,7 @@ export class RuntimeService { const incognito = chromeSender.tab?.incognito ?? false; const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); - // Retire bindings even when the new URL has no matching scripts. This closes - // the reuse window on browsers that do not provide documentId. + // 即使新 URL 没有匹配脚本也要退休旧绑定,关闭不提供 documentId 的浏览器复用窗口。 this.revokePageBindings(sender, data?.envTag); this.mq.emit("popupPageLoadUpdate", { diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 951370783..ab8cd169c 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -65,7 +65,7 @@ export type ServiceWorkerExecutionBinding = { documentId?: string; /** 用于只向运行该脚本的文档投递值更新的存储命名空间。 */ storageName: string; - /** Capability names accepted by the isolated GM API broker for this page execution. */ + /** 隔离 GM API broker 为本次页面执行接受的能力名称。 */ allowedAPIs: ReadonlySet; /** 已接受的页面请求 ID;绑定销毁时一并释放,确保绑定存续期间拒绝重放。 */ requestIds: Set; diff --git a/src/content.ts b/src/content.ts index 6722c19bc..cba8cb9ff 100644 --- a/src/content.ts +++ b/src/content.ts @@ -19,8 +19,8 @@ const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.content; - // USER_SCRIPT has a native extension messaging channel. Keep the DOM channel only - // for the synchronous element helper, whose node references must remain in this realm. + // USER_SCRIPT 使用浏览器原生扩展通道;DOM 通道只保留同步元素辅助 API, + // 因为节点引用必须留在当前 content realm。 const msg: Message = new ExtensionMessage(false); const domMsg = new CustomEventMessage(eventFlag, false, scriptEnvTag); const domContentMsg = new CustomEventMessage(eventFlag, true, scriptEnvTag); From 9d0de585ce4d236ac630bc6c73daacc64d54f3f3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:07:36 +0900 Subject: [PATCH 020/106] =?UTF-8?q?=F0=9F=94=92=20separate=20asynchronous?= =?UTF-8?q?=20page=20RPC=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.test.ts | 70 ++++++++ packages/message/page_message.ts | 210 ++++++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 5 +- src/app/service/content/scripting.test.ts | 1 + src/app/service/content/scripting.ts | 6 +- src/inject.ts | 3 +- src/scripting.ts | 6 +- 7 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 packages/message/page_message.test.ts create mode 100644 packages/message/page_message.ts diff --git a/packages/message/page_message.test.ts b/packages/message/page_message.test.ts new file mode 100644 index 000000000..7a2a4a72e --- /dev/null +++ b/packages/message/page_message.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PageMessage } from "./page_message"; + +type FakeWindow = Window & { + handlers: Set<(event: MessageEvent) => void>; +}; + +const createWindow = (): FakeWindow => { + const handlers = new Set<(event: MessageEvent) => void>(); + const target = { + handlers, + addEventListener: vi.fn((_type: string, handler: (event: MessageEvent) => void) => { + handlers.add(handler); + }), + removeEventListener: vi.fn((_type: string, handler: (event: MessageEvent) => void) => { + handlers.delete(handler); + }), + postMessage: vi.fn((data: unknown) => { + queueMicrotask(() => { + for (const handler of handlers) handler({ source: target, data } as unknown as MessageEvent); + }); + }), + } as unknown as FakeWindow; + return target; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PageMessage", () => { + it("routes structured-clone messages only to the opposite role", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn((_data, sendResponse) => sendResponse({ code: 0, data: "pong" })); + inject.onMessage(received); + + const response = await scripting.sendMessage({ action: "inject/ping", data: "ping" }); + + expect(response).toEqual({ code: 0, data: "pong" }); + expect(received).toHaveBeenCalledWith( + { action: "inject/ping", data: "ping" }, + expect.any(Function), + expect.any(Object) + ); + expect(target.postMessage).toHaveBeenCalledTimes(2); + + scripting.dispose(); + inject.dispose(); + }); + + it("supports scoped connections and removes its listener on dispose", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onConnect((_data, connection) => connection.onMessage(received)); + + const connection = await scripting.connect({ action: "inject/connect" }); + connection.sendMessage({ action: "inject/message", data: 1 }); + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(received).toHaveBeenCalledWith({ action: "inject/message", data: 1 }); + const handlerCount = target.handlers.size; + scripting.dispose(); + expect(target.handlers.size).toBe(handlerCount - 1); + inject.dispose(); + }); +}); diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts new file mode 100644 index 000000000..c1be57325 --- /dev/null +++ b/packages/message/page_message.ts @@ -0,0 +1,210 @@ +import EventEmitter from "eventemitter3"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { + Message, + MessageConnect, + OnConnectCallback, + OnMessageCallback, + RuntimeMessageSender, + TMessage, +} from "./types"; + +export type PageMessageRole = "scripting" | "inject"; + +type PageMessageType = "sendMessage" | "respMessage" | "connect" | "disconnect" | "connectMessage"; + +type PageMessageBody = { + readonly channel: string; + readonly source: PageMessageRole; + readonly target: PageMessageRole; + readonly messageId: string; + readonly type: PageMessageType; + readonly data: TMessage | null; +}; + +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; + +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +const listenerMgr = new EventEmitter(); + +const otherRole = (role: PageMessageRole): PageMessageRole => (role === "scripting" ? "inject" : "scripting"); + +class PageMessageConnect implements MessageConnect { + private readonly listenerId = uuidv4(); + private target: (() => void) | null; + private isSelfDisconnected = false; + + constructor( + private readonly messageId: string, + private readonly targetRole: PageMessageRole, + private readonly send: ( + target: PageMessageRole, + body: Omit + ) => void, + private readonly EE: EventEmitter + ) { + const handler = (message: TMessage) => { + listenerMgr.emit(`onMessage:${this.listenerId}`, message); + }; + const cleanup = () => { + if (!this.target) return; + this.target = null; + listenerMgr.removeAllListeners(`cleanup:${this.listenerId}`); + this.EE.removeAllListeners(`connectMessage:${this.messageId}`); + this.EE.removeAllListeners(`disconnect:${this.messageId}`); + listenerMgr.emit(`onDisconnect:${this.listenerId}`, this.isSelfDisconnected); + listenerMgr.removeAllListeners(`onDisconnect:${this.listenerId}`); + listenerMgr.removeAllListeners(`onMessage:${this.listenerId}`); + }; + this.target = cleanup; + this.EE.addListener(`connectMessage:${this.messageId}`, handler); + this.EE.addListener(`disconnect:${this.messageId}`, cleanup); + listenerMgr.once(`cleanup:${this.listenerId}`, cleanup); + } + + sendMessage(data: TMessage): void { + if (!this.target) throw new Error("Attempted to sendMessage on a disconnected page channel."); + this.send(this.targetRole, { + messageId: this.messageId, + type: "connectMessage", + data, + }); + } + + onMessage(callback: (data: TMessage) => void): void { + if (!this.target) throw new Error("onMessage on a disconnected page channel."); + listenerMgr.addListener(`onMessage:${this.listenerId}`, callback); + } + + disconnect(ignoreAlreadyDisconnected = false): void { + if (!this.target) { + if (ignoreAlreadyDisconnected) return; + throw new Error("Attempted to disconnect a disconnected page channel."); + } + this.isSelfDisconnected = true; + this.send(this.targetRole, { + messageId: this.messageId, + type: "disconnect", + data: null, + }); + listenerMgr.emit(`cleanup:${this.listenerId}`); + } + + onDisconnect(callback: (isSelfDisconnected: boolean) => void): void { + if (!this.target) throw new Error("onDisconnect on a disconnected page channel."); + listenerMgr.once(`onDisconnect:${this.listenerId}`, callback); + } +} + +/** + * 页面异步 RPC 专用通道。 + * + * role 与 channel 只负责传输路由;调用方仍必须验证每个请求,并把权限绑定到隔离执行记录。 + */ +export class PageMessage implements Message { + readonly EE = new EventEmitter(); + private readonly postMessage: (message: unknown, targetOrigin: string) => void; + private readonly messageHandler: (event: MessageEvent) => void; + private readonly targetRole: PageMessageRole; + + constructor( + private readonly channel: string, + private readonly role: PageMessageRole, + private readonly sourceWindow: Window = window + ) { + if (typeof sourceWindow.postMessage !== "function") throw new TypeError("window.postMessage is unavailable"); + this.postMessage = bindNative(sourceWindow.postMessage, sourceWindow); + this.targetRole = otherRole(role); + this.messageHandler = (event: MessageEvent) => { + if (event.source !== null && event.source !== sourceWindow) return; + const body = event.data as Partial | null; + if ( + !body || + body.channel !== this.channel || + body.target !== this.role || + body.source !== this.targetRole || + typeof body.messageId !== "string" || + typeof body.type !== "string" + ) { + return; + } + this.messageHandle(body as PageMessageBody); + }; + sourceWindow.addEventListener("message", this.messageHandler); + } + + private sendEnvelope(target: PageMessageRole, body: Omit): void { + this.postMessage( + { + channel: this.channel, + source: this.role, + target, + ...body, + } satisfies PageMessageBody, + "*" + ); + } + + private messageHandle(body: PageMessageBody): void { + if (body.type === "sendMessage") { + this.EE.emit( + "message", + body.data, + (response: TMessage) => { + this.sendEnvelope(body.source, { + messageId: body.messageId, + type: "respMessage", + data: response, + }); + }, + {} as RuntimeMessageSender + ); + } else if (body.type === "respMessage") { + this.EE.emit(`response:${body.messageId}`, body); + } else if (body.type === "connect") { + this.EE.emit( + "connect", + body.data, + new PageMessageConnect(body.messageId, body.source, this.sendEnvelope.bind(this), this.EE) + ); + } else if (body.type === "disconnect") { + this.EE.emit(`disconnect:${body.messageId}`); + } else if (body.type === "connectMessage") { + this.EE.emit(`connectMessage:${body.messageId}`, body.data); + } + } + + onConnect(callback: OnConnectCallback): void { + this.EE.addListener("connect", callback); + } + + onMessage(callback: OnMessageCallback): void { + this.EE.addListener("message", callback); + } + + connect(data: TMessage): Promise { + const messageId = uuidv4(); + this.sendEnvelope(this.targetRole, { messageId, type: "connect", data }); + return Promise.resolve(new PageMessageConnect(messageId, this.targetRole, this.sendEnvelope.bind(this), this.EE)); + } + + sendMessage(data: TMessage): Promise { + return new Promise((resolve) => { + const messageId = uuidv4(); + const eventId = `response:${messageId}`; + this.EE.addListener(eventId, (body: PageMessageBody) => { + this.EE.removeAllListeners(eventId); + resolve(body.data as T); + }); + this.sendEnvelope(this.targetRole, { messageId, type: "sendMessage", data }); + }); + } + + dispose(): void { + this.sourceWindow.removeEventListener("message", this.messageHandler); + this.EE.removeAllListeners(); + } +} diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 95e312659..576ee1890 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -601,7 +601,8 @@ export default class GMApi extends GM_Base { // 上下文已失效时直接返回,避免访问已释放的 message 造成异常 if (ctx.isInvalidContext()) return undefined; - if (ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content) { + const isContentEnv = ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content; + if (isContentEnv) { // USER_SCRIPT 可直接在 content realm 创建 Document;跨到 scripting 只会丢失节点引用。 return new Promise((resolve) => { const xhr = new XMLHttpRequest(); @@ -613,8 +614,6 @@ export default class GMApi extends GM_Base { }); } - const message = ctx.message as CustomEventMessage | null; - const isContentEnv = !!message && message.envTag === ScriptEnvTag.content; return urlToDocumentInContentPage(ctx, url, isContentEnv); } diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts index 960eb6b27..2cf1c6329 100644 --- a/src/app/service/content/scripting.test.ts +++ b/src/app/service/content/scripting.test.ts @@ -41,6 +41,7 @@ describe("ScriptingRuntime page bootstrap", () => { {} as Server, senderToExt as unknown as MessageSend, senderToContent as any, + senderToInject as any, senderToInject as any ); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 304fc7a5c..caf88b916 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -39,7 +39,9 @@ export default class ScriptingRuntime { // 发送给 content的消息接口 private readonly senderToContent: CustomEventMessage, // 发送给inject的消息接口 - private readonly senderToInject: CustomEventMessage + private readonly senderToInject: MessageSend, + // 仅用于同步 DOM 节点引用;异步脚本 RPC 使用 senderToInject 的结构化消息。 + private readonly domSenderToInject: CustomEventMessage ) {} // 广播消息给 content 和 inject @@ -115,7 +117,7 @@ export default class ScriptingRuntime { case "CAT_fetchDocument": { const [url, isContent] = data.params; // 根据来源选择不同的消息桥(content / inject) - let msg: CustomEventMessage | null = isContent ? this.senderToContent : this.senderToInject; + let msg: CustomEventMessage | null = isContent ? this.senderToContent : this.domSenderToInject; return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; diff --git a/src/inject.ts b/src/inject.ts index 0d13290da..878f2426c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,6 +1,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { PageMessage } from "@Packages/message/page_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; @@ -14,7 +15,7 @@ const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.inject; - const msg: Message = new CustomEventMessage(eventFlag, false, scriptEnvTag); + const msg: Message = new PageMessage(eventFlag, "inject"); // 初始化日志组件 const logger = new LoggerCore({ diff --git a/src/scripting.ts b/src/scripting.ts index fa943e7ba..2f4a88ce1 100644 --- a/src/scripting.ts +++ b/src/scripting.ts @@ -3,6 +3,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import type { Message } from "@Packages/message/types"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { PageMessage } from "@Packages/message/page_message"; import { ScriptEnvTag } from "@Packages/message/consts"; import { Server } from "@Packages/message/server"; import ScriptingRuntime from "./app/service/content/scripting"; @@ -24,7 +25,8 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { logger.logger().debug("scripting start"); const contentMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.content); - const injectMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.inject); + const injectMsg = new PageMessage(eventFlag, "scripting"); + const domInjectMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.inject); const server = new Server("scripting", [contentMsg, injectMsg]); @@ -33,7 +35,7 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { const extServer = new Server("scripting", extMsgComm, false); // scriptExecutor的消息接口 // 初始化运行环境 - const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg); + const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg, domInjectMsg); runtime.init(); // 页面加载,注入脚本 runtime.pageLoad(); From 34487280190ad0ed529c07b26fe3b151f45d4898 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:14:51 +0900 Subject: [PATCH 021/106] =?UTF-8?q?=F0=9F=94=92=20hide=20GM=20broker=20sta?= =?UTF-8?q?te=20behind=20script=20facade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 42 +++++++------ src/app/service/content/create_context.ts | 61 +++++++++++++++++-- src/app/service/content/exec_script.ts | 9 ++- src/app/service/content/gm_api/gm_api.test.ts | 4 +- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 13495e87a..b22232d8f 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -306,6 +306,16 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("does not expose broker state on the script-facing context", () => { + const context = createTestContext(["GM_getValue"]); + + expect(context).not.toHaveProperty("message"); + expect(context).not.toHaveProperty("scriptRes"); + expect(context).not.toHaveProperty("valueChangeListener"); + expect(context).not.toHaveProperty("EE"); + expect(context).not.toHaveProperty("grantSet"); + }); + it("creates collection instances from frozen captured-method subclasses", () => { const set = new Native.Set(["grant"]); const map = new Native.Map(); @@ -365,21 +375,29 @@ describe("createContext: capability and lifecycle contract", () => { } }); - it("uses the service-worker execution run flag for value acknowledgments", () => { + it("uses the service-worker execution run flag for value acknowledgments", async () => { const script = { - ...createScriptInfo({ grant: ["GM_getValue"] }), + ...createScriptInfo({ grant: ["GM_setValue"] }), executionRunFlag: "canonical-run", } as TScriptInfo; + const message = { + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: "bar" }), + }; const context = createContext( script, { script: { name: "create-context-test" }, scriptMetaStr: "" }, "vitest", + message as any, undefined as any, - undefined as any, - new Set(["GM_getValue"]) + new Set(["GM_setValue"]) ); - expect((context as unknown as { runFlag: string }).runFlag).toBe("canonical-run"); + context.GM_setValue("foo", "next"); + expect(message.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ runFlag: "canonical-run" }), + }) + ); }); it("installs capabilities without looking up a page-patchable Function.prototype.bind", () => { @@ -465,9 +483,6 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_cookie.list).toBeTypeOf("function"); expect(context.GM_cookie.delete).toBeTypeOf("function"); expect(context.not_exist).toBeUndefined(); - expect(context.grantSet.has("not_exist")).toBe(false); - expect(context.grantSet.has("GM_getValue")).toBe(true); - expect(context.grantSet.has("GM.getValue")).toBe(true); }); it.each(["GM.cookie", "GM_cookie"] as const)("雙向注入 cookie API:輸入 %s 時兩種公開形狀都可用", (grant) => { @@ -481,8 +496,6 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_cookie.set).toBeTypeOf("function"); expect(context.GM_cookie.list).toBeTypeOf("function"); expect(context.GM_cookie.delete).toBeTypeOf("function"); - expect(context.grantSet.has("GM.cookie")).toBe(true); - expect(context.grantSet.has("GM_cookie")).toBe(true); }); it("將 window grant 留在 context.window,投影時才暴露到 sandbox", () => { @@ -509,8 +522,7 @@ describe("createContext: capability and lifecycle contract", () => { await Promise.resolve(); expect(loaded).toBe(false); - const loadScriptResolve = (context as unknown as AnyRecord).loadScriptResolve as () => void; - loadScriptResolve(); + context.resolveLoadScript(); await loadedPromise; expect(loaded).toBe(true); }); @@ -549,16 +561,10 @@ describe("createContext: capability and lifecycle contract", () => { update("remote-1", "next", 7); expect(listener).toHaveBeenCalledWith("foo", "bar", "next", true, 7); - const contextValues = context as unknown as AnyRecord; - const runFlag = contextValues.runFlag; context.setInvalidContext(); context.setInvalidContext(); expect(context.isInvalidContext()).toBe(true); - expect(contextValues.runFlag).not.toBe(runFlag); - expect(contextValues.runFlag).toContain("(invalid)"); - expect(contextValues.message).toBeNull(); - expect(contextValues.scriptRes).toBeNull(); update("remote-2", "again", 8); expect(listener).toHaveBeenCalledTimes(1); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 990d3da3d..8b572ba14 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -6,7 +6,7 @@ import { GMContextApiGet, 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"; +import { createGMBase, type IGM_Base } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; import { nativeCall, Native } from "./global"; @@ -44,6 +44,18 @@ const createCapability = (api: (...args: any[]) => any, receiver: object) => { // 不要使用 {}, 改使用 Object.create(null) - 避免在页面生成沙盒时,受到 Object.prototype 被注入的影响 +export type ScriptContext = IGM_Base & { + [key: string]: any; + setExecutionRunFlag(runFlag: string): void; + resolveLoadScript(): void; +}; + +type InternalScriptContext = IGM_Base & { + [key: string]: any; + runFlag: string; + loadScriptResolve?: () => void; +}; + // 构建沙盒上下文 export const createContext = ( scriptRes: TScriptInfo, @@ -99,7 +111,47 @@ export const createContext = ( isInvalidContext() { return invalid; }, + }) as unknown as InternalScriptContext; + const publicContext = Native.objectCreate(null) as ScriptContext; + publicContext.GM = GM; + publicContext.GM_info = GMInfo; + publicContext.window = Native.objectCreate(null); + publicContext.unsafeWindow = window; + + // 生命周期方法只供隔离执行器使用,不进入脚本可枚举的 facade。 + Native.objectDefineProperty(publicContext, "valueUpdate", { + configurable: false, + enumerable: false, + value: (data: any) => context.valueUpdate(data), + }); + Native.objectDefineProperty(publicContext, "emitEvent", { + configurable: false, + enumerable: false, + value: (event: string, eventId: string, data: any) => context.emitEvent(event, eventId, data), + }); + Native.objectDefineProperty(publicContext, "setInvalidContext", { + configurable: false, + enumerable: false, + value: () => context.setInvalidContext(), }); + Native.objectDefineProperty(publicContext, "isInvalidContext", { + configurable: false, + enumerable: false, + value: () => context.isInvalidContext(), + }); + Native.objectDefineProperty(publicContext, "setExecutionRunFlag", { + configurable: false, + enumerable: false, + value: (runFlag: string) => { + context.runFlag = runFlag; + }, + }); + Native.objectDefineProperty(publicContext, "resolveLoadScript", { + configurable: false, + enumerable: false, + value: () => context.loadScriptResolve?.(), + }); + const grantedAPIs: { [key: string]: any } = Native.objectCreate(null); const __methodInject__ = (grant: string): boolean => { const grantSet: Set = context.grantSet; @@ -131,7 +183,7 @@ export const createContext = ( const fnKey = grantedKeys[i]; const fnKeyArray = fnKey.split("."); const m = fnKeyArray.length; - let g = context; + let g = publicContext; let s = ""; for (let i = 0; i < m; i++) { const part = fnKeyArray[i]; @@ -139,12 +191,11 @@ export const createContext = ( g = g[part] || (g[part] = grantedAPIs[s] || Native.objectCreate(null)); } } - context.unsafeWindow = window; if (scriptGrantSet.has("window.onurlchange") && context.onurlchange === undefined) { - context.onurlchange = null; + publicContext.onurlchange = null; attachNavigateHandler(window as any); } - return context; + return publicContext; }; const noEval = false; diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index dedcce66d..e0acb4c0c 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -1,12 +1,11 @@ import LoggerCore from "@App/app/logger/core"; import type Logger from "@App/app/logger/logger"; -import { createContext, createProxyContext } from "./create_context"; +import { createContext, createProxyContext, type ScriptContext } from "./create_context"; import type { GMInfoEnv, ScriptFunc } from "./types"; import { compileScript, isContextMenuScript } from "./utils"; import type { Message } from "@Packages/message/types"; import type { ValueUpdateDataEncoded } from "./types"; import { evaluateGMInfo } from "./gm_api/gm_info"; -import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; import { Native } from "./global"; @@ -23,7 +22,7 @@ export default class ExecScript { // proxyContext: typeof globalThis; - sandboxContext?: IGM_Base & { [key: string]: any }; + sandboxContext?: ScriptContext; named?: { [key: string]: any }; @@ -103,13 +102,13 @@ export default class ExecScript { this.scriptRes.executionEnvTag = scriptInfo.executionEnvTag; this.scriptRes.executionRunFlag = scriptInfo.executionRunFlag; if (this.sandboxContext && scriptInfo.executionRunFlag) { - this.sandboxContext.runFlag = scriptInfo.executionRunFlag; + this.sandboxContext.setExecutionRunFlag(scriptInfo.executionRunFlag); } } let GM_info; if (this.sandboxContext) { // 触发loadScriptResolve - this.sandboxContext["loadScriptResolve"]?.(); + this.sandboxContext.resolveLoadScript(); GM_info = this.execContext["GM_info"]; } else { GM_info = this.named?.GM_info; 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 94f3dfb70..d73add0b5 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1249,7 +1249,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 entries: [["param1", encodeRValue(123), encodeRValue(undefined)]], uuid: script.uuid, storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + sender: { runFlag: script.executionRunFlag, tabId: -2 }, valueUpdated: true, }); const ret = await retPromise; @@ -1350,7 +1350,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 entries: [["a", encodeRValue(123), encodeRValue(undefined)]], uuid: script.uuid, storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + sender: { runFlag: actualCall.data.runFlag, tabId: -2 }, valueUpdated: true, }); From 6a014bfb4b2a7519ee49614182464ac9a0f6039e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:22:09 +0900 Subject: [PATCH 022/106] =?UTF-8?q?=F0=9F=94=92=20reject=20executable=20va?= =?UTF-8?q?lues=20at=20GM=20clone=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 48 +++++++++++++++---- src/app/service/content/gm_api/gm_api.test.ts | 26 ++++++++-- src/app/service/content/utils.ts | 21 ++------ 3 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 8e7702946..95a9808e5 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -25,6 +25,9 @@ const nativeWeakMapSet = WeakMap.prototype.set; const nativeWeakMapHas = WeakMap.prototype.has; const nativeWeakMapDelete = WeakMap.prototype.delete; const nativeObjectFreeze = Object.freeze; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const hasNativeStructuredClone = typeof structuredClone === "function"; // Keep the captured methods on private subclasses. Instances can then be created // without reassigning every method, while the subclass prototypes remain outside @@ -103,17 +106,46 @@ export const customClone = (o: any) => { // 接受参数:阵列、物件、null if (typeof o !== "object") return o; - try { - // 优先使用 structuredClone,支持大多数可克隆对象 - return Native.structuredClone(o); - } catch { - // 例如:被 Proxy 包装的对象(如 Vue 等框架处理过的 reactive 对象) - // structuredClone 可能会失败,忽略错误继续尝试其他方式 + // 先验证自有字段都是数据描述符,避免 JSON fallback 执行页面 getter 或 Proxy trap。 + const seen = new Native.WeakMap(); + const isDataOnly = (value: object): boolean => { + if (seen.has(value)) return true; + seen.set(value, true); + let keys: PropertyKey[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + return false; + } + for (const key of keys) { + if (typeof key === "symbol") return false; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + } catch { + return false; + } + if (!descriptor || !("value" in descriptor)) return false; + if (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) { + return false; + } + } + return true; + }; + if (!isDataOnly(o)) return undefined; + + if (hasNativeStructuredClone) { + try { + // 优先使用 structuredClone,支持大多数可克隆对象 + return Native.structuredClone(o); + } catch { + // structuredClone 拒绝的值不再退回会执行 getter 的 JSON 序列化。 + return undefined; + } } try { - // 退而求其次,使用 JSON 序列化方式进行深拷贝 - // 仅适用于可被 JSON 表示的普通对象 + // 旧浏览器没有 structuredClone 时,只复制已验证的数据属性。 return Native.jsonParse(Native.jsonStringify(o)); } catch { // 序列化失败,忽略错误 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 d73add0b5..a64cbd1b6 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -740,7 +740,7 @@ describe.concurrent("GM_value", () => { action: "scripting/runtime/gmApi", data: { api: "GM_setValue", - params: [expect.any(String), "proxy-key", {}], // Proxy 会被转换为空对象 + params: [expect.any(String), "proxy-key"], // Proxy 无法通过 data-only clone,按删除处理 runFlag: expect.any(String), uuid: undefined, }, @@ -764,7 +764,7 @@ describe.concurrent("GM_value", () => { expect(ret).toEqual({ ret1: 123, ret2: 456, - ret3: {}, + ret3: undefined, ret4: undefined, }); }); @@ -927,6 +927,24 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); + it("拒绝带 getter 的值,且不会在克隆时执行 getter", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const getter = vi.fn(() => "secret"); + const payload = {} as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValue(api, "hostile", payload); + + expect(getter).not.toHaveBeenCalled(); + expect(script.value.hostile).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "hostile"] }) }) + ); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; @@ -1020,7 +1038,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 // event id expect.stringMatching(/^.+::\d+$/), // the object payload - [["proxy-key", encodeRValue({})]], + [["proxy-key", encodeRValue(undefined)]], ], runFlag: expect.any(String), uuid: undefined, @@ -1055,7 +1073,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(ret).toEqual({ ret1: { a: 123, b: 456, c: "789" }, ret2: { b: 456 }, - ret3: { "proxy-key": {} }, + ret3: { "proxy-key": undefined }, ret4: { window: undefined }, }); }); diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 32f8f6c62..f8c779e11 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -7,26 +7,11 @@ 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"; - -const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; -const nativeJSONStringify = JSON.stringify.bind(JSON); -const nativeJSONParse = JSON.parse.bind(JSON); +import { customClone } from "./global"; const cloneTransportValue = (value: any) => { - // USER_SCRIPT 只能接收数据副本;先去掉 Proxy、getter 和原型引用,避免把页面对象带过边界。 - if (value === null || typeof value !== "object") return value; - if (nativeStructuredClone) { - try { - return nativeStructuredClone(value); - } catch { - // Fall through for objects such as proxies that structuredClone rejects. - } - } - try { - return nativeJSONParse(nativeJSONStringify(value)); - } catch { - return undefined; - } + // USER_SCRIPT 只能接收数据副本;共享 customClone 的 data-only 检查,避免 getter/Proxy 进入页面资料。 + return customClone(value); }; // 与 rspack 注入的构建级密钥配对;页面只能看到包装函数,拿不到正确的调用标记。 From eb66888815196db291ab823738c5ca35e21198dd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:23:36 +0900 Subject: [PATCH 023/106] =?UTF-8?q?=F0=9F=90=9B=20settle=20GM=20XHR=20abor?= =?UTF-8?q?ts=20without=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 30 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index cf75a3a61..8daeb7ea5 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -70,4 +70,34 @@ describe("GM_xmlhttpRequest callback cleanup", () => { expect(connection.disconnect).toHaveBeenCalledWith(true); expect(onloadend).toHaveBeenCalledTimes(1); }); + + it("aborts and releases the connection even without an onabort callback", async () => { + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(connection.onMessage).toHaveBeenCalled()); + request.abort(); + + await expect(request.retPromise).rejects.toBe("AbortError"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 96eb9f23f..e2065f3bf 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -752,7 +752,7 @@ export function GM_xmlhttpRequest( connect.disconnect(true); // 断开连结(容忍已断开) connect = null; } - if (doAbort && details.onabort && !reqDone) { + if (doAbort && !reqDone) { // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort // When a request is aborted, its readyState is changed to XMLHttpRequest.UNSENT (0) and the request's status code is set to 0. doAbort?.({ From 1d0dd8504b53c613177f56e6235f2330e91dc9ef Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:25:24 +0900 Subject: [PATCH 024/106] =?UTF-8?q?=F0=9F=90=9B=20continue=20page=20script?= =?UTF-8?q?=20loading=20after=20early=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 36 +++++++++++++++++++ src/app/service/content/script_executor.ts | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 969243ab1..31dcd154c 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -186,6 +186,42 @@ describe("ScriptExecutor", () => { } }); + it("continues loading later scripts after reconciling an early-start entry", () => { + const early = makeScript({ + uuid: "early-script", + flag: "executor-early-batch", + metadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const later = makeScript({ uuid: "later-script", flag: "executor-later-batch" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: early, + scriptFlag: early.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + const internal = executor as unknown as { + earlyScriptFlags: Set; + execScripts: Map void }>; + }; + internal.earlyScriptFlags.add(early.flag); + const updateEarlyScriptGMInfo = vi.spyOn(internal.execScripts.get(early.uuid)!, "updateEarlyScriptGMInfo"); + const genuine = vi.fn(); + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + const pageWindow = window as unknown as Record; + + try { + executor.startScripts([early, later], initEnvInfo); + pageWindow[later.flag] = genuine; + + expect(updateEarlyScriptGMInfo).toHaveBeenCalledWith(initEnvInfo, early); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, later.name); + } finally { + delete pageWindow[later.flag]; + } + }); + describe("resource execution", () => { let adoptedSheets: CSSStyleSheet[]; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 75df76f12..68b9b980f 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -81,7 +81,7 @@ export class ScriptExecutor { updated = true; } }); - if (updated) return; + if (updated) continue; } const listenForScript = () => { definePropertyListener(window, flag, (val: ScriptFunc) => { From 7780934cec327fa540e2b79359e14f71839e63e6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:27:08 +0900 Subject: [PATCH 025/106] =?UTF-8?q?=F0=9F=94=92=20clone=20DOM=20bridge=20p?= =?UTF-8?q?ayloads=20before=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_runtime.test.ts | 48 +++++++++++++++++++ src/app/service/content/script_runtime.ts | 6 ++- 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 src/app/service/content/script_runtime.test.ts diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts new file mode 100644 index 000000000..217399ffb --- /dev/null +++ b/src/app/service/content/script_runtime.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message } from "@Packages/message/types"; +import type { Server } from "@Packages/message/server"; +import type { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { ScriptRuntime } from "./script_runtime"; + +describe("ScriptRuntime DOM bridge", () => { + it("rejects accessor attributes without executing their getters", () => { + let handler: ((data: any) => unknown) | undefined; + const server = { + on: vi.fn((_name: string, callback: (data: any) => unknown) => { + handler = callback; + }), + } as unknown as Server; + const runtime = new ScriptRuntime("ct", server, {} as Message, {} as any, undefined); + runtime.contentInit(server, {} as CustomEventMessage); + + const getter = vi.fn(() => "secret"); + const attrs = {} as Record; + Object.defineProperty(attrs, "id", { configurable: true, enumerable: true, get: getter }); + + expect(handler?.({ params: [null, "div", attrs] })).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + }); + + it("creates an element only from the cloned flat attribute payload", () => { + let handler: ((data: any) => unknown) | undefined; + const domMessage = { + getAndDelRelatedTarget: vi.fn(), + sendRelatedTarget: vi.fn(() => 1), + } as unknown as CustomEventMessage; + const server = { + on: vi.fn((_name: string, callback: (data: any) => unknown) => { + handler = callback; + }), + } as unknown as Server; + const runtime = new ScriptRuntime("ct", server, {} as Message, {} as any, undefined); + runtime.contentInit(server, domMessage); + + const result = handler?.({ params: [null, "div", { id: "safe", textContent: "hello" }] }); + + expect(result).toBe(1); + expect(domMessage.sendRelatedTarget).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + const element = (domMessage.sendRelatedTarget as any).mock.calls[0][0] as HTMLDivElement; + expect(element.id).toBe("safe"); + expect(element.textContent).toBe("hello"); + }); +}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 5c76cbfcf..cd0434094 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -9,6 +9,7 @@ import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { type TExtensionEnv } from "../extension/extension_env"; import { RuntimeClient } from "../service_worker/client"; +import { customClone } from "./global"; export class ScriptRuntime { constructor( @@ -22,8 +23,9 @@ export class ScriptRuntime { // content环境的特殊初始化 contentInit(domServer: Server = this.server, domMsg: CustomEventMessage = this.msg as CustomEventMessage) { domServer.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { - if (!data || !Array.isArray(data.params) || data.params.length !== 3) return undefined; - const [parentNodeId, tagName, tmpAttr] = data.params; + const safeData = customClone(data) as typeof data | undefined; + if (!safeData || !Array.isArray(safeData.params) || safeData.params.length !== 3) return undefined; + const [parentNodeId, tagName, tmpAttr] = safeData.params; // 此请求来自页面事件,只接受可验证的节点编号、标签名和扁平属性,避免把对象行为带入 DOM 操作。 if ( From 2993bbc3ecc26de559f3e1bfa2b1762db3a232b0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:30:07 +0900 Subject: [PATCH 026/106] =?UTF-8?q?=F0=9F=94=92=20validate=20Blob=20page?= =?UTF-8?q?=20RPC=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 23 +++++++++++++++++++++++ src/app/service/content/page_rpc.ts | 10 +++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index eee9bc780..1b80a697f 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Blob as NodeBlob } from "node:buffer"; import { getPageRpcAllowedAPIs, setPageRpcExtensionOrigin, @@ -211,6 +212,28 @@ describe("page GM RPC", () => { expect(isExtensionBlobUrl("blob:chrome-extension://other/internal")).toBe(false); }); + it("requires a Blob for CAT_createBlobUrl after parameter cloning", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["CAT_createBlobUrl"]); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "object", handle, api: "CAT_createBlobUrl", params: [{}] }, + registry + ) + ).toThrow("CAT_createBlobUrl expects one Blob value"); + + const blob = new NodeBlob(["payload"], { type: "text/plain" }); + expect(Object.prototype.toString.call(blob)).toBe("[object Blob]"); + expect(Object.prototype.toString.call(structuredClone(blob))).toBe("[object Blob]"); + const request = validatePageGMRequest( + { version: 1, requestId: "blob", handle, api: "CAT_createBlobUrl", params: [blob] }, + registry + ); + expect(Object.prototype.toString.call(request.params[0])).toBe("[object Blob]"); + expect(request.params[0]).not.toBe(blob); + }); + it("validates extension blobs in USER_SCRIPT when runtime.getURL is unavailable", () => { const runtime = chrome.runtime as unknown as { getURL?: typeof chrome.runtime.getURL }; const getURL = runtime.getURL; diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 8d9b93403..3f49debaf 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -6,6 +6,7 @@ export const PAGE_RPC_VERSION = 1 as const; const MAX_REQUEST_ID_LENGTH = 256; const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeObjectToString = Object.prototype.toString; const EXTENSION_PROTOCOLS = new Set(["chrome-extension:", "moz-extension:"]); export type ExtensionOrigin = Pick; @@ -182,6 +183,9 @@ const ownData = (value: object, key: PropertyKey): unknown => { const assertDataOnly = (value: unknown, seen: Set): void => { // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 if (value === null || typeof value !== "object") return; + // Blob 的内部槽由浏览器管理,不能把其 symbol/accessor 细节当作 DTO 字段遍历。 + if (typeof Blob === "function" && (value instanceof Blob || nativeObjectToString.call(value) === "[object Blob]")) + return; if (seen.has(value)) return; seen.add(value); @@ -218,7 +222,11 @@ const validateOperationParams = (api: string, params: readonly unknown[]): void } return; case "CAT_createBlobUrl": - if (params.length !== 1 || params[0] === null || typeof params[0] !== "object") { + if ( + params.length !== 1 || + typeof Blob !== "function" || + (!(params[0] instanceof Blob) && nativeObjectToString.call(params[0]) !== "[object Blob]") + ) { throw new PageRpcError("CAT_createBlobUrl expects one Blob value"); } return; From 9c0650d159da1113f1dfa43eeb955e7afe31a621 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:36:37 +0900 Subject: [PATCH 027/106] =?UTF-8?q?=F0=9F=94=92=20reject=20executable=20GM?= =?UTF-8?q?=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 9 ++++++--- src/app/service/content/gm_api/gm_api.test.ts | 15 +++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 8 ++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 95a9808e5..f12dcdb2a 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -102,9 +102,9 @@ export const Native = { } as const; export const customClone = (o: any) => { - // 非对象类型直接返回(包含 Symbol、undefined、基本类型等) + // 非对象类型直接返回(包含 Symbol、undefined、基本类型等);函数不可跨边界传输。 // 接受参数:阵列、物件、null - if (typeof o !== "object") return o; + if (o === null || typeof o !== "object") return typeof o === "function" ? undefined : o; // 先验证自有字段都是数据描述符,避免 JSON fallback 执行页面 getter 或 Proxy trap。 const seen = new Native.WeakMap(); @@ -126,7 +126,10 @@ export const customClone = (o: any) => { return false; } if (!descriptor || !("value" in descriptor)) return false; - if (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) { + if ( + typeof descriptor.value === "function" || + (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) + ) { return false; } } 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 a64cbd1b6..3d4c32b56 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -945,6 +945,21 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 ); }); + it("拒绝可执行值,且不会把函数写入本地存储或传输层", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const executable = () => "secret"; + + api.GM_setValue(api, "executable", executable); + + expect(script.value.executable).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "executable"] }) }) + ); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 576ee1890..fd05120de 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -347,8 +347,8 @@ export default class GMApi extends GM_Base { delete a.scriptRes.value[key]; a.sendMessage("GM_setValue", [id, key]); } else { - // 对object的value进行一次转化 - if (value && typeof value === "object") { + // 对对象或函数值进行一次转化 + if (value !== null && (typeof value === "object" || typeof value === "function")) { value = customClone(value); } // customClone 可能返回 undefined @@ -380,8 +380,8 @@ export default class GMApi extends GM_Base { if (value_ === undefined) { if (valueStore[key]) delete valueStore[key]; } else { - // 对object的value进行一次转化 - if (value_ && typeof value_ === "object") { + // 对对象或函数值进行一次转化 + if (value_ !== null && (typeof value_ === "object" || typeof value_ === "function")) { value_ = customClone(value_); } // customClone 可能返回 undefined From 398c8845027597228c71d6329500573e47d0c5d1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:38:37 +0900 Subject: [PATCH 028/106] =?UTF-8?q?=F0=9F=94=92=20reject=20non-cloneable?= =?UTF-8?q?=20GM=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 6 ++++-- src/app/service/content/gm_api/gm_api.test.ts | 14 ++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 8 ++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index f12dcdb2a..b1d9701ef 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -102,9 +102,11 @@ export const Native = { } as const; export const customClone = (o: any) => { - // 非对象类型直接返回(包含 Symbol、undefined、基本类型等);函数不可跨边界传输。 + // 非对象类型直接返回(包含 undefined、基本类型等);函数和 Symbol 不可跨边界传输。 // 接受参数:阵列、物件、null - if (o === null || typeof o !== "object") return typeof o === "function" ? undefined : o; + if (o === null || typeof o !== "object") { + return typeof o === "function" || typeof o === "symbol" ? undefined : o; + } // 先验证自有字段都是数据描述符,避免 JSON fallback 执行页面 getter 或 Proxy trap。 const seen = new Native.WeakMap(); 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 3d4c32b56..8c76153b2 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -960,6 +960,20 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 ); }); + it("拒绝 Symbol 值,避免把不可结构化克隆的数据写入本地存储", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValue(api, "symbol", Symbol("secret")); + + expect(script.value.symbol).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "symbol"] }) }) + ); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index fd05120de..eb3ea9fb2 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -348,7 +348,7 @@ export default class GMApi extends GM_Base { a.sendMessage("GM_setValue", [id, key]); } else { // 对对象或函数值进行一次转化 - if (value !== null && (typeof value === "object" || typeof value === "function")) { + if (typeof value === "function" || typeof value === "symbol" || (value !== null && typeof value === "object")) { value = customClone(value); } // customClone 可能返回 undefined @@ -381,7 +381,11 @@ export default class GMApi extends GM_Base { if (valueStore[key]) delete valueStore[key]; } else { // 对对象或函数值进行一次转化 - if (value_ !== null && (typeof value_ === "object" || typeof value_ === "function")) { + if ( + typeof value_ === "function" || + typeof value_ === "symbol" || + (value_ !== null && typeof value_ === "object") + ) { value_ = customClone(value_); } // customClone 可能返回 undefined From 06bbf29190f9b600fb5382fb0aca5b4a0dfe1cb8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:44:10 +0900 Subject: [PATCH 029/106] =?UTF-8?q?=F0=9F=94=92=20harden=20page=20RPC=20in?= =?UTF-8?q?trinsic=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 25 ++++++++- src/app/service/content/page_rpc.ts | 66 +++++++++++++++--------- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 1b80a697f..16b74a999 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Blob as NodeBlob } from "node:buffer"; import { getPageRpcAllowedAPIs, @@ -190,6 +190,29 @@ describe("page GM RPC", () => { ).toThrow(PageRpcError); }); + it("keeps validation on captured intrinsics after page prototype hooks", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const ownKeysSpy = vi.spyOn(Reflect, "ownKeys").mockImplementation(() => { + throw new Error("page hook"); + }); + const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor").mockImplementation(() => { + throw new Error("page hook"); + }); + + let result: ReturnType | undefined; + try { + result = validatePageGMRequest( + { version: 1, requestId: "hooked", handle, api: "GM_getValue", params: [] }, + registry + ); + } finally { + ownKeysSpy.mockRestore(); + descriptorSpy.mockRestore(); + } + expect(result).toMatchObject({ uuid: "script-a", envTag: "it" }); + }); + it("rejects malformed parameters for privileged helper operations", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["CAT_fetchBlob"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 3f49debaf..089afca36 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -1,20 +1,27 @@ import { uuidv4 } from "@App/pkg/utils/uuid"; import type { ScriptEnvTag } from "@Packages/message/consts"; import { getGrantCandidates } from "./gm_api/grant"; +import { Native, nativeReflectApply } from "./global"; export const PAGE_RPC_VERSION = 1 as const; const MAX_REQUEST_ID_LENGTH = 256; const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; const nativeObjectToString = Object.prototype.toString; -const EXTENSION_PROTOCOLS = new Set(["chrome-extension:", "moz-extension:"]); +const EXTENSION_PROTOCOLS = new Native.Set(["chrome-extension:", "moz-extension:"]); +const nativeReflectOwnKeys = Native.reflectOwnKeys; +const nativeObjectGetOwnPropertyDescriptor = Native.objectGetOwnPropertyDescriptor; +const nativeArrayIsArray = Array.isArray; +const nativeURL = URL; +const nativeBlob = typeof Blob === "function" ? Blob : undefined; +const nativeStringSlice = String.prototype.slice; export type ExtensionOrigin = Pick; export const getExtensionOrigin = (): ExtensionOrigin | undefined => { if (typeof chrome === "undefined" || typeof chrome.runtime?.getURL !== "function") return undefined; try { - const url = new URL(chrome.runtime.getURL("/")); + const url = new nativeURL(chrome.runtime.getURL("/")); if (!EXTENSION_PROTOCOLS.has(url.protocol) || !url.hostname) return undefined; return { protocol: url.protocol, hostname: url.hostname, port: url.port }; } catch { @@ -33,7 +40,7 @@ export const setPageRpcExtensionOrigin = (value: unknown): void => { } try { const read = (key: keyof ExtensionOrigin): unknown => { - const descriptor = Object.getOwnPropertyDescriptor(value, key); + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); return descriptor && "value" in descriptor ? descriptor.value : undefined; }; const protocol = read("protocol"); @@ -59,9 +66,9 @@ export const isExtensionBlobUrl = (value: unknown): value is string => { const extensionOrigin = configuredExtensionOrigin || getExtensionOrigin(); if (!extensionOrigin) return false; try { - const url = new URL(value); + const url = new nativeURL(value); if (url.protocol !== "blob:") return false; - const creatorOrigin = new URL(value.slice("blob:".length)); + const creatorOrigin = new nativeURL(nativeReflectApply(nativeStringSlice, value, ["blob:".length])); return ( creatorOrigin.protocol === extensionOrigin.protocol && creatorOrigin.hostname === extensionOrigin.hostname && @@ -150,8 +157,8 @@ const API_DEPENDENCIES: Readonly> = { export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { if (grants.some((grant) => grant === "none")) return []; - const allowed = new Set(); - const visited = new Set(); + const allowed = new Native.Set(); + const visited = new Native.Set(); const visitGrant = (grant: string): void => { for (const candidate of getGrantCandidates(grant)) { if (visited.has(candidate)) continue; @@ -162,7 +169,9 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { } }; for (const grant of grants) visitGrant(grant); - return [...allowed]; + const result: string[] = []; + allowed.forEach((value) => result.push(value)); + return result; }; export class PageRpcError extends Error { @@ -173,7 +182,7 @@ export class PageRpcError extends Error { } const ownData = (value: object, key: PropertyKey): unknown => { - const descriptor = Object.getOwnPropertyDescriptor(value, key); + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); if (!descriptor || !("value" in descriptor)) { throw new PageRpcError(`page RPC field ${String(key)} must be a data property`); } @@ -191,7 +200,7 @@ const assertDataOnly = (value: unknown, seen: Set): void => { let keys: (string | symbol)[]; try { - keys = Reflect.ownKeys(value); + keys = nativeReflectOwnKeys(value); } catch { throw new PageRpcError("page RPC value cannot be inspected"); } @@ -204,8 +213,8 @@ const assertDataOnly = (value: unknown, seen: Set): void => { const cloneParams = (params: unknown): readonly unknown[] => { // 复制发生在交给 service worker 之前,后续 broker 只处理隔离后的普通值。 - if (!Array.isArray(params)) throw new PageRpcError("page RPC params must be an array"); - assertDataOnly(params, new Set()); + if (!nativeArrayIsArray(params)) throw new PageRpcError("page RPC params must be an array"); + assertDataOnly(params, new Native.Set()); if (!nativeStructuredClone) throw new PageRpcError("structured clone is unavailable"); try { return nativeStructuredClone(params) as readonly unknown[]; @@ -224,8 +233,8 @@ const validateOperationParams = (api: string, params: readonly unknown[]): void case "CAT_createBlobUrl": if ( params.length !== 1 || - typeof Blob !== "function" || - (!(params[0] instanceof Blob) && nativeObjectToString.call(params[0]) !== "[object Blob]") + !nativeBlob || + (!(params[0] instanceof nativeBlob) && nativeObjectToString.call(params[0]) !== "[object Blob]") ) { throw new PageRpcError("CAT_createBlobUrl expects one Blob value"); } @@ -240,7 +249,7 @@ const validateOperationParams = (api: string, params: readonly unknown[]): void params.length !== 1 || params[0] === null || typeof params[0] !== "object" || - Array.isArray(params[0]) || + nativeArrayIsArray(params[0]) || typeof (params[0] as { action?: unknown }).action !== "string" ) { throw new PageRpcError("CAT_agentOPFS expects an operation object"); @@ -252,7 +261,7 @@ const validateOperationParams = (api: string, params: readonly unknown[]): void }; export class PageRpcRegistry { - private readonly bindings = new Map(); + private readonly bindings = new Native.Map(); register( uuid: string, @@ -268,10 +277,10 @@ export class PageRpcRegistry { handle, uuid, envTag, - allowedAPIs: new Set(allowedAPIs), + allowedAPIs: new Native.Set(allowedAPIs), runFlag, active: true, - requestIds: new Set(), + requestIds: new Native.Set(), }); return handle; } @@ -310,16 +319,27 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) let keys: (string | symbol)[]; try { - keys = Reflect.ownKeys(value); + keys = nativeReflectOwnKeys(value); } catch { throw new PageRpcError("page RPC request cannot be inspected"); } - if ( - keys.length !== REQUEST_KEYS.length || - keys.some((key) => typeof key !== "string" || !REQUEST_KEYS.includes(key as never)) - ) { + if (keys.length !== REQUEST_KEYS.length) { throw new PageRpcError("page RPC request has unexpected fields"); } + for (const key of keys) { + let knownKey = false; + if (typeof key === "string") { + for (const expected of REQUEST_KEYS) { + if (expected === key) { + knownKey = true; + break; + } + } + } + if (!knownKey) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + } const version = ownData(value, "version"); const requestId = ownData(value, "requestId"); From 4a4a1271a1aa04b5853ecd74254f41a09e937ece Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:47:41 +0900 Subject: [PATCH 030/106] =?UTF-8?q?=F0=9F=90=9B=20preserve=20zero-valued?= =?UTF-8?q?=20tab=20identities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.test.ts | 17 +++++++++++++ packages/message/server.ts | 8 +++---- .../service/service_worker/gm_api/gm_api.ts | 8 +++---- .../service/service_worker/runtime.test.ts | 24 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 4 ++-- 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index 3b572c0ac..e10861fb6 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -532,6 +532,23 @@ describe("Server", () => { expect(extSender.documentId).toBe("doc-123"); }); + it("应该保留有效的零标签页和窗口编号", () => { + let capturedSender: IGetSender; + + server.on("on-zero-ids", (_params, sender) => { + capturedSender = sender; + }); + + const mockSender: RuntimeMessageSender = { + tab: { id: 0, windowId: 0 }, + frameId: 0, + } as RuntimeMessageSender; + + (server as any).messageHandle("on-zero-ids", {}, vi.fn(), mockSender); + + expect(capturedSender!.getExtMessageSender()).toMatchObject({ tabId: 0, windowId: 0, frameId: 0 }); + }); + it("应该把扩展消息来源传给 SenderRuntime", () => { let capturedOrigin: string | undefined; server.on("on-origin", (_params, sender) => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 71f2f8415..511451a73 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -62,8 +62,8 @@ export class SenderConnect { if (this.sender instanceof ExtensionMessageConnect) { const con = this.sender.getPort(); return { - windowId: con.sender?.tab?.windowId || -1, // -1表示后台脚本 - tabId: con.sender?.tab?.id || -1, // -1表示后台脚本 + windowId: con.sender?.tab?.windowId ?? -1, // -1表示后台脚本 + tabId: con.sender?.tab?.id ?? -1, // -1表示后台脚本 frameId: con.sender?.frameId, documentId: con.sender?.documentId, }; @@ -119,8 +119,8 @@ export class SenderRuntime { }; } return { - windowId: sender.tab?.windowId || -1, // -1表示后台脚本 - tabId: sender.tab?.id || -1, // -1表示后台脚本 + windowId: sender.tab?.windowId ?? -1, // -1表示后台脚本 + tabId: sender.tab?.id ?? -1, // -1表示后台脚本 frameId: sender.frameId, documentId: sender.documentId, }; diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 095ab64d1..28a051041 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -641,7 +641,7 @@ export default class GMApi { const keyValuePairs = [[key, encodeRValue(value)]] as TKeyValuePair[]; const valueSender = { runFlag: request.runFlag, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, }; await this.value.setValues({ uuid: request.script.uuid, id, keyValuePairs, isReplace: false, valueSender }); } @@ -654,7 +654,7 @@ export default class GMApi { const [id, keyValuePairs] = request.params; const valueSender = { runFlag: request.runFlag, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, }; await this.value.setValues({ uuid: request.script.uuid, id, keyValuePairs, isReplace: false, valueSender }); } @@ -1176,7 +1176,7 @@ export default class GMApi { key, name, options, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, frameId: sender.getSender()?.frameId, documentId: sender.getSender()?.documentId, }); @@ -1189,7 +1189,7 @@ export default class GMApi { this.mq.emit("unregisterMenuCommand", { uuid: request.script.uuid, key, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, frameId: sender.getSender()?.frameId, documentId: sender.getSender()?.documentId, }); diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 581bf8a67..99e47b59f 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1104,6 +1104,30 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); + it("preserves tab ID zero for page matching and BFCache reporting", async () => { + const { runtime, mockGroup } = _createRuntimeContext(); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue(null); + const sender = new SenderRuntime({ + ...createSender(false), + tab: { ...(createSender(false).tab as chrome.tabs.Tab), id: 0 } as chrome.tabs.Tab, + }); + + await runtime.pageLoad(undefined, sender); + await runtime.pageShow(undefined, sender); + + expect(getScriptsForTab).toHaveBeenCalledWith({ + url: "https://www.example.com/page", + tabId: 0, + frameId: 0, + incognito: false, + }); + expect(mockGroup.emit).toHaveBeenCalledWith("popupPageRestored", { + tabId: 0, + frameId: 0, + url: "https://www.example.com/page", + }); + }); + // bfcache 还原不会重新注入 content script,页面里的脚本却还活着; // 这条上报只用来重新确认「本页扩展触及得到」,绝不能顺带重放脚本。 it("bfcache 还原上报只广播 popupPageRestored,不重新下发脚本", async () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index bb6784168..7415f97ac 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1533,7 +1533,7 @@ export class RuntimeService { // 异常加载 return { ok: false }; } - const tabId = chromeSender.tab?.id || -1; + const tabId = chromeSender.tab?.id ?? -1; const frameId = chromeSender.frameId; const incognito = chromeSender.tab?.incognito ?? false; const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); @@ -1602,7 +1602,7 @@ export class RuntimeService { const url = chromeSender?.url; if (!url) return; this.mq.emit("popupPageRestored", { - tabId: chromeSender.tab?.id || -1, + tabId: chromeSender.tab?.id ?? -1, frameId: chromeSender.frameId, url, }); From a5a9b8491a93452772f0dd44929a1f9d0053baec Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:49:50 +0900 Subject: [PATCH 031/106] =?UTF-8?q?=F0=9F=90=9B=20delete=20falsy=20GM=20va?= =?UTF-8?q?lues=20consistently?= 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 | 13 +++++++++++++ src/app/service/content/gm_api/gm_api.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) 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 8c76153b2..6dc943b2c 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -974,6 +974,19 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 ); }); + it("GM_setValues deletes existing falsy values when given undefined", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: { zero: 0, no: false, empty: "", nil: null }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValues(api, { zero: undefined, no: undefined, empty: undefined, nil: undefined }); + + expect(script.value).toEqual({}); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index eb3ea9fb2..09e98d229 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -378,7 +378,7 @@ export default class GMApi extends GM_Base { for (const [key, value] of Object.entries(values)) { let value_ = value; if (value_ === undefined) { - if (valueStore[key]) delete valueStore[key]; + if (Native.objectHasOwn(valueStore, key)) delete valueStore[key]; } else { // 对对象或函数值进行一次转化 if ( From 340fea94abd623ba37ebd92eacde4e5d64f0a98e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:51:17 +0900 Subject: [PATCH 032/106] =?UTF-8?q?=F0=9F=94=92=20avoid=20accessor=20execu?= =?UTF-8?q?tion=20in=20GM=5FsetValues?= 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 | 17 +++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 12 +++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) 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 6dc943b2c..dbb91e9f7 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -945,6 +945,23 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 ); }); + it("GM_setValues skips accessor fields without invoking them", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const getter = vi.fn(() => "secret"); + const payload = { valid: 1 } as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValues(api, payload); + + expect(getter).not.toHaveBeenCalled(); + expect(script.value).toEqual({ valid: 1 }); + }); + it("拒绝可执行值,且不会把函数写入本地存储或传输层", () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 09e98d229..714efcd3a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -375,7 +375,17 @@ export default class GMApi extends GM_Base { } const valueStore = a.scriptRes.value; const keyValuePairs = [] as [string, REncoded][]; - for (const [key, value] of Object.entries(values)) { + const valueEntries: [string, unknown][] = []; + const valueKeys = Native.reflectOwnKeys(values); + for (let index = 0; index < valueKeys.length; index += 1) { + const key = valueKeys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(values, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + valueEntries.push([key, descriptor.value]); + } + for (let index = 0; index < valueEntries.length; index += 1) { + const [key, value] = valueEntries[index]; let value_ = value; if (value_ === undefined) { if (Native.objectHasOwn(valueStore, key)) delete valueStore[key]; From 884642ad6a4fb038344b60e88ac8d78d90b58cbd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:52:39 +0900 Subject: [PATCH 033/106] =?UTF-8?q?=F0=9F=94=92=20preserve=20none=20grant?= =?UTF-8?q?=20capability=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 13 +++++++++++++ src/app/service/content/page_rpc.ts | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 16b74a999..f8848649d 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -55,6 +55,19 @@ describe("page GM RPC", () => { expect(getPageRpcAllowedAPIs(["none", "GM_getValue", "CAT.agent.dom"])).toEqual([]); }); + it("honors a none grant when Array.prototype.some is hooked", () => { + const originalSome = Array.prototype.some; + Array.prototype.some = (() => false) as typeof Array.prototype.some; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["none", "GM_getValue"]); + } finally { + Array.prototype.some = originalSome; + } + + expect(allowed!).toEqual([]); + }); + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 089afca36..8c64482ca 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -156,7 +156,9 @@ const API_DEPENDENCIES: Readonly> = { }; export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { - if (grants.some((grant) => grant === "none")) return []; + for (let index = 0; index < grants.length; index += 1) { + if (grants[index] === "none") return []; + } const allowed = new Native.Set(); const visited = new Native.Set(); const visitGrant = (grant: string): void => { @@ -168,7 +170,7 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { for (const dependency of API_DEPENDENCIES[candidate] || []) visitGrant(dependency); } }; - for (const grant of grants) visitGrant(grant); + for (let index = 0; index < grants.length; index += 1) visitGrant(grants[index]); const result: string[] = []; allowed.forEach((value) => result.push(value)); return result; From 3d45436f1bfae7ada3b16088fd80e523e18718f5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:53:50 +0900 Subject: [PATCH 034/106] =?UTF-8?q?=F0=9F=94=92=20capture=20grant=20alias?= =?UTF-8?q?=20intrinsics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/grant.ts | 12 ++++++++---- src/app/service/content/page_rpc.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/gm_api/grant.ts b/src/app/service/content/gm_api/grant.ts index ab1e91c39..579e03112 100644 --- a/src/app/service/content/gm_api/grant.ts +++ b/src/app/service/content/gm_api/grant.ts @@ -1,9 +1,13 @@ +const nativeReflectApply = Reflect.apply; +const nativeStringStartsWith = String.prototype.startsWith; +const nativeStringSlice = String.prototype.slice; + export function getGrantCandidates(grant: string): string[] { - if (grant.startsWith("GM.")) { - return [grant, `GM_${grant.slice(3)}`]; + if (nativeReflectApply(nativeStringStartsWith, grant, ["GM."])) { + return [grant, `GM_${nativeReflectApply(nativeStringSlice, grant, [3])}`]; } - if (grant.startsWith("GM_")) { - return [grant, `GM.${grant.slice(3)}`]; + if (nativeReflectApply(nativeStringStartsWith, grant, ["GM_"])) { + return [grant, `GM.${nativeReflectApply(nativeStringSlice, grant, [3])}`]; } return [grant]; } diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index f8848649d..1e478524e 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -68,6 +68,20 @@ describe("page GM RPC", () => { expect(allowed!).toEqual([]); }); + it("does not let a hooked String.prototype.slice enlarge grant aliases", () => { + const originalSlice = String.prototype.slice; + String.prototype.slice = (() => "xmlhttpRequest") as typeof String.prototype.slice; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["GM.getValue"]); + } finally { + String.prototype.slice = originalSlice; + } + + expect(allowed!).toContain("GM_getValue"); + expect(allowed!).not.toContain("GM_xmlhttpRequest"); + }); + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); From 764a5505a6d0c973fd64b87abd933ffeffa98490 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:54:56 +0900 Subject: [PATCH 035/106] =?UTF-8?q?=F0=9F=94=92=20isolate=20grant=20capabi?= =?UTF-8?q?lity=20map=20lookups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 4 ++++ src/app/service/content/page_rpc.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 1e478524e..9421c8e3c 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -82,6 +82,10 @@ describe("page GM RPC", () => { expect(allowed!).not.toContain("GM_xmlhttpRequest"); }); + it("ignores inherited capability-map properties for unknown grant names", () => { + expect(getPageRpcAllowedAPIs(["constructor", "toString"])).toEqual(["constructor", "toString"]); + }); + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 8c64482ca..e68fa55b8 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -166,8 +166,14 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { if (visited.has(candidate)) continue; visited.add(candidate); allowed.add(candidate); - for (const api of INTERNAL_APIS_BY_GRANT[candidate] || []) allowed.add(api); - for (const dependency of API_DEPENDENCIES[candidate] || []) visitGrant(dependency); + if (Native.objectHasOwn(INTERNAL_APIS_BY_GRANT, candidate)) { + const internalAPIs = INTERNAL_APIS_BY_GRANT[candidate]; + for (let index = 0; index < internalAPIs.length; index += 1) allowed.add(internalAPIs[index]); + } + if (Native.objectHasOwn(API_DEPENDENCIES, candidate)) { + const dependencies = API_DEPENDENCIES[candidate]; + for (let index = 0; index < dependencies.length; index += 1) visitGrant(dependencies[index]); + } } }; for (let index = 0; index < grants.length; index += 1) visitGrant(grants[index]); From 525b2ed9fbdaa7295f0f0ac8aad08f04b7960002 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:57:22 +0900 Subject: [PATCH 036/106] =?UTF-8?q?=F0=9F=94=92=20harden=20capability=20co?= =?UTF-8?q?llection=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.test.ts | 6 ++++++ src/app/service/content/global.ts | 3 ++- src/app/service/content/page_rpc.test.ts | 15 +++++++++++++++ src/app/service/content/page_rpc.ts | 8 ++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index b22232d8f..8d6f723e9 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -334,6 +334,7 @@ describe("createContext: capability and lifecycle contract", () => { it("keeps grant construction on captured Set and iterator intrinsics", () => { const NativeSet = Set; + const nativeArrayIsArray = Array.isArray; const nativeArrayIterator = Array.prototype[Symbol.iterator]; const nativeSetIterator = Set.prototype[Symbol.iterator]; const grants = new NativeSet(); @@ -349,6 +350,7 @@ describe("createContext: capability and lifecycle contract", () => { }; }; try { + Array.isArray = (() => false) as unknown as typeof Array.isArray; Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); (globalThis as typeof globalThis & { Set: typeof Set }).Set = class PoisonedSet { @@ -357,6 +359,9 @@ describe("createContext: capability and lifecycle contract", () => { } } as unknown as typeof Set; + const arrayBackedSet = new Native.Set(["GM_getValue"]); + expect(arrayBackedSet.has("GM_getValue")).toBe(true); + const context = createContext( createScriptInfo({ grant: ["GM_getValue"] }), { script: { name: "create-context-test" }, scriptMetaStr: "" }, @@ -369,6 +374,7 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_getValue).toBeTypeOf("function"); expect(context.GM_cookie).toBeUndefined(); } finally { + Array.isArray = nativeArrayIsArray; Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: nativeArrayIterator }); Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: nativeSetIterator }); (globalThis as typeof globalThis & { Set: typeof Set }).Set = NativeSet; diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index b1d9701ef..72caf3414 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -12,6 +12,7 @@ const nativeSetHas = Set.prototype.has; const nativeSetDelete = Set.prototype.delete; const nativeSetClear = Set.prototype.clear; const nativeSetForEach = Set.prototype.forEach; +const nativeArrayIsArray = Array.isArray; const nativeMapConstructor = Map; const nativeMapGet = Map.prototype.get; const nativeMapSet = Map.prototype.set; @@ -36,7 +37,7 @@ const NativeSetConstructor = class extends nativeSetConstructor { constructor(values?: readonly T[] | Set | null) { // 不把 values 传给 Set 构造器:它会读取 values 的 @@iterator,而页面可改写该方法。 super(); - if (Array.isArray(values)) { + if (nativeArrayIsArray(values)) { for (let i = 0; i < values.length; i += 1) this.add(values[i]); } else if (values) { nativeReflectApply(nativeSetForEach, values, [(value: T) => this.add(value)]); diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 9421c8e3c..9274e5d70 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -86,6 +86,21 @@ describe("page GM RPC", () => { expect(getPageRpcAllowedAPIs(["constructor", "toString"])).toEqual(["constructor", "toString"]); }); + it("does not let a hooked Array.prototype.push enlarge the capability result", () => { + const originalPush = Array.prototype.push; + Array.prototype.push = function (...items: unknown[]): number { + return originalPush.call(this, ...items, "GM_xmlhttpRequest"); + }; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["GM_getValue"]); + } finally { + Array.prototype.push = originalPush; + } + + expect(allowed!).not.toContain("GM_xmlhttpRequest"); + }); + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index e68fa55b8..525383497 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -162,7 +162,9 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { const allowed = new Native.Set(); const visited = new Native.Set(); const visitGrant = (grant: string): void => { - for (const candidate of getGrantCandidates(grant)) { + const candidates = getGrantCandidates(grant); + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; if (visited.has(candidate)) continue; visited.add(candidate); allowed.add(candidate); @@ -178,7 +180,9 @@ export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { }; for (let index = 0; index < grants.length; index += 1) visitGrant(grants[index]); const result: string[] = []; - allowed.forEach((value) => result.push(value)); + allowed.forEach((value) => { + result[result.length] = value; + }); return result; }; From c37446a337259d6686d0291337681d3a324b137c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:59:00 +0900 Subject: [PATCH 037/106] =?UTF-8?q?=F0=9F=94=92=20seal=20page=20RPC=20coll?= =?UTF-8?q?ection=20iteration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 2 ++ src/app/service/content/page_rpc.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 72caf3414..cd1c0942e 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -12,6 +12,7 @@ const nativeSetHas = Set.prototype.has; const nativeSetDelete = Set.prototype.delete; const nativeSetClear = Set.prototype.clear; const nativeSetForEach = Set.prototype.forEach; +const nativeSetValues = Set.prototype.values; const nativeArrayIsArray = Array.isArray; const nativeMapConstructor = Map; const nativeMapGet = Map.prototype.get; @@ -49,6 +50,7 @@ NativeSetConstructor.prototype.has = nativeSetHas; NativeSetConstructor.prototype.delete = nativeSetDelete; NativeSetConstructor.prototype.clear = nativeSetClear; NativeSetConstructor.prototype.forEach = nativeSetForEach; +NativeSetConstructor.prototype.values = nativeSetValues; nativeObjectFreeze(NativeSetConstructor.prototype); const NativeMapConstructor = class extends nativeMapConstructor {}; diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 525383497..4106e0fdb 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -205,8 +205,7 @@ const assertDataOnly = (value: unknown, seen: Set): void => { // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 if (value === null || typeof value !== "object") return; // Blob 的内部槽由浏览器管理,不能把其 symbol/accessor 细节当作 DTO 字段遍历。 - if (typeof Blob === "function" && (value instanceof Blob || nativeObjectToString.call(value) === "[object Blob]")) - return; + if (nativeBlob && (value instanceof nativeBlob || nativeObjectToString.call(value) === "[object Blob]")) return; if (seen.has(value)) return; seen.add(value); @@ -216,7 +215,8 @@ const assertDataOnly = (value: unknown, seen: Set): void => { } catch { throw new PageRpcError("page RPC value cannot be inspected"); } - for (const key of keys) { + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; if (typeof key === "symbol") throw new PageRpcError("page RPC values cannot contain symbol properties"); const child = ownData(value, key); assertDataOnly(child, seen); @@ -338,10 +338,12 @@ export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry) if (keys.length !== REQUEST_KEYS.length) { throw new PageRpcError("page RPC request has unexpected fields"); } - for (const key of keys) { + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; let knownKey = false; if (typeof key === "string") { - for (const expected of REQUEST_KEYS) { + for (let expectedIndex = 0; expectedIndex < REQUEST_KEYS.length; expectedIndex += 1) { + const expected = REQUEST_KEYS[expectedIndex]; if (expected === key) { knownKey = true; break; From 18255a04bb3eca19b7734e1c3e186cdc4a17cee9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:01:09 +0900 Subject: [PATCH 038/106] =?UTF-8?q?=F0=9F=90=9B=20honor=20early=20GM=20XHR?= =?UTF-8?q?=20aborts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 29 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 11 +++++++ 2 files changed, 40 insertions(+) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 8daeb7ea5..44d3f1015 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -100,4 +100,33 @@ describe("GM_xmlhttpRequest callback cleanup", () => { expect(connection.disconnect).toHaveBeenCalledWith(true); await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); }); + + it("honors abort requested before the native connection is ready", async () => { + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onloadend, + }, + true + ); + + request.abort(); + + await expect(request.retPromise).rejects.toBe("AbortError"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index e2065f3bf..e1e6850b3 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -189,6 +189,7 @@ export function GM_xmlhttpRequest( isDownload: boolean = false ) { let reqDone = false; + let abortRequested = false; if (a.isInvalidContext()) { return { retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, @@ -743,11 +744,21 @@ export function GM_xmlhttpRequest( }; connect?.onMessage((msgData) => onMessageHandler?.(msgData)); + if (abortRequested && !reqDone) { + doAbort?.({ + error: "aborted", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg); + } })(); // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 return { retPromise, abort: () => { + abortRequested = true; if (connect) { connect.disconnect(true); // 断开连结(容忍已断开) connect = null; From ad61560ba8c18a756624790b10b72c0ed2a20d4c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:03:59 +0900 Subject: [PATCH 039/106] =?UTF-8?q?=F0=9F=90=9B=20settle=20GM=20XHR=20conn?= =?UTF-8?q?ection=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 23 +++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 39 ++++++++++++------- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 44d3f1015..e3692ad39 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -129,4 +129,27 @@ describe("GM_xmlhttpRequest callback cleanup", () => { expect(connection.disconnect).toHaveBeenCalledWith(true); await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); }); + + it("settles the request when connection setup rejects", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockRejectedValue(new Error("connection failed")), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("connection failed"); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index e1e6850b3..09f8cd7a0 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -196,8 +196,8 @@ export function GM_xmlhttpRequest( abort: () => {}, }; } - let retPromiseResolve: (value: unknown) => void | undefined; - let retPromiseReject: (reason?: any) => void | undefined; + let retPromiseResolve: ((value: unknown) => void) | undefined; + let retPromiseReject: ((reason?: any) => void) | undefined; const retPromise = requirePromise ? new Promise((resolve, reject) => { retPromiseResolve = resolve; @@ -278,19 +278,30 @@ export function GM_xmlhttpRequest( } } // 发送信息 - let connectMessage: Promise; - if (isDownload) { - // 如果是下载,带上 downloadMode 参数,呼叫 SW 的 GM_download - // 在 SW 中处理,实际使用 GM_xmlhttpRequest 进行下载 - const method = param.method === "POST" ? "POST" : "GET"; - const downloadParam: GMTypes.DownloadDetails = { ...param, method, downloadMode: "native", name: "" }; - connectMessage = a.connect("GM_download", [downloadParam]); - } else { - // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest - connectMessage = a.connect("GM_xmlhttpRequest", [param]); + try { + let connectMessage: Promise; + if (isDownload) { + // 如果是下载,带上 downloadMode 参数,呼叫 SW 的 GM_download + // 在 SW 中处理,实际使用 GM_xmlhttpRequest 进行下载 + const method = param.method === "POST" ? "POST" : "GET"; + const downloadParam: GMTypes.DownloadDetails = { ...param, method, downloadMode: "native", name: "" }; + connectMessage = a.connect("GM_download", [downloadParam]); + } else { + // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest + connectMessage = a.connect("GM_xmlhttpRequest", [param]); + } + param = null; // GC + connect = await connectMessage; + } catch (error) { + param = null; + const message = error instanceof Error ? error.message : `${error}`; + reqDone = true; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeXHRCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeXHRCallback("onloadend", details.onloadend, response); + return; } - param = null; // GC - connect = await connectMessage; const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC const resultBuffers = [] as Uint8Array[]; // 函数参考清掉后,变数会被GC From 68f4d3c1d7c72efb705fe4b0204460e77b928f15 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:05:56 +0900 Subject: [PATCH 040/106] =?UTF-8?q?=F0=9F=90=9B=20settle=20GM=20XHR=20setu?= =?UTF-8?q?p=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 25 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 10 +++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index e3692ad39..945df925c 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -152,4 +152,29 @@ describe("GM_xmlhttpRequest callback cleanup", () => { expect(onerror).toHaveBeenCalledTimes(1); expect(onloadend).toHaveBeenCalledTimes(1); }); + + it("settles the request when data encoding rejects before connection setup", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn(), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + data: Promise.reject(new Error("data failed")) as unknown as GMTypes.XHRDetails["data"], + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("data failed"); + expect(api.connect).not.toHaveBeenCalled(); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 09f8cd7a0..32c7dd940 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -764,7 +764,15 @@ export function GM_xmlhttpRequest( statusText: "", } as TXhrCallBackArg); } - })(); + })().catch((error) => { + if (reqDone) return; + reqDone = true; + const message = error instanceof Error ? error.message : `${error}`; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeXHRCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeXHRCallback("onloadend", details.onloadend, response); + }); // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 return { retPromise, From f4edd72e33e37f2f0ba83cb040eb2b2e842d9172 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:08:08 +0900 Subject: [PATCH 041/106] =?UTF-8?q?=F0=9F=94=92=20discard=20stale=20page?= =?UTF-8?q?=20load=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 34 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 19 +++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 99e47b59f..8168b8257 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1128,6 +1128,40 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); + it("discards an older same-frame pageLoad response that resolves after a newer one", async () => { + const { runtime } = _createRuntimeContext(); + const firstScript = _createScriptRunResource(_createMockScript({ uuid: "first-page-load" })); + const secondScript = _createScriptRunResource(_createMockScript({ uuid: "second-page-load" })); + const loadResult = (script: ScriptRunResource) => + ({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + }) as unknown as Awaited>; + let resolveFirst!: (result: Awaited>) => void; + let resolveSecond!: (result: Awaited>) => void; + vi.spyOn(runtime, "getScriptsForTab") + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve))); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const firstLoad = runtime.pageLoad(undefined, sender); + const secondLoad = runtime.pageLoad(undefined, sender); + resolveSecond(loadResult(secondScript)); + const second = await secondLoad; + expect(second.ok).toBe(true); + const secondHandle = second.ok ? second.injectScriptList[0].executionHandle : undefined; + + resolveFirst(loadResult(firstScript)); + await expect(firstLoad).resolves.toEqual({ ok: false }); + expect(runtime.resolvePageExecutionBinding(secondHandle!, sender)).toBeDefined(); + }); + // bfcache 还原不会重新注入 content script,页面里的脚本却还活着; // 这条上报只用来重新确认「本页扩展触及得到」,绝不能顺带重放脚本。 it("bfcache 还原上报只广播 popupPageRestored,不重新下发脚本", async () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 7415f97ac..1e7578be7 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -160,6 +160,8 @@ export class RuntimeService { documentId?: string; } >(); + // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. + private readonly pageLoadSequences = new Map(); getGMApi(): GMApi | undefined { return this.gmApi; @@ -213,6 +215,19 @@ export class RuntimeService { for (const [token, bootstrap] of this.userScriptBootstraps) { if (bootstrap.tabId === tabId) this.userScriptBootstraps.delete(token); } + const prefix = `${tabId}:`; + for (const key of this.pageLoadSequences.keys()) { + if (key.startsWith(prefix)) this.pageLoadSequences.delete(key); + } + } + + private beginPageLoadSequence(sender: IGetSender, envTag: "it" | "ct" | undefined): [string, number] | undefined { + const tabId = sender.getSender()?.tab?.id; + if (typeof tabId !== "number") return undefined; + const key = `${tabId}:${sender.getSender()?.frameId ?? -1}:${envTag ?? "it"}`; + const sequence = (this.pageLoadSequences.get(key) ?? 0) + 1; + this.pageLoadSequences.set(key, sequence); + return [key, sequence]; } private userScriptConnectionKey(tabId: number, frameId?: number, documentId?: string): string { @@ -1536,7 +1551,11 @@ export class RuntimeService { const tabId = chromeSender.tab?.id ?? -1; const frameId = chromeSender.frameId; const incognito = chromeSender.tab?.incognito ?? false; + const pageLoadSequence = this.beginPageLoadSequence(sender, data?.envTag); const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); + if (pageLoadSequence && this.pageLoadSequences.get(pageLoadSequence[0]) !== pageLoadSequence[1]) { + return { ok: false }; + } // 即使新 URL 没有匹配脚本也要退休旧绑定,关闭不提供 documentId 的浏览器复用窗口。 this.revokePageBindings(sender, data?.envTag); From 842d5eedc86d3582bf6cfa1441f5d866fc274d2e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:10:22 +0900 Subject: [PATCH 042/106] =?UTF-8?q?=F0=9F=94=92=20harden=20GM=20value=20tr?= =?UTF-8?q?ansport=20collection=20writes?= 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 | 24 +++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) 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 dbb91e9f7..6768c66de 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -962,6 +962,30 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(script.value).toEqual({ valid: 1 }); }); + it("GM_setValues does not trust a hooked Array.prototype.push for transport", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const originalPush = Array.prototype.push; + Array.prototype.push = function (...items: unknown[]): number { + return originalPush.call(this, ...items, ["injected", encodeRValue("forged")]); + }; + + try { + api.GM_setValues(api, { valid: 1 }); + } finally { + Array.prototype.push = originalPush; + } + + expect(script.value).toEqual({ valid: 1 }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), [["valid", [0, 1]]]] }) }) + ); + }); + it("拒绝可执行值,且不会把函数写入本地存储或传输层", () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 714efcd3a..f5b29b929 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -382,7 +382,7 @@ export default class GMApi extends GM_Base { if (typeof key !== "string") continue; const descriptor = Native.objectGetOwnPropertyDescriptor(values, key); if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; - valueEntries.push([key, descriptor.value]); + valueEntries[valueEntries.length] = [key, descriptor.value]; } for (let index = 0; index < valueEntries.length; index += 1) { const [key, value] = valueEntries[index]; @@ -402,7 +402,7 @@ export default class GMApi extends GM_Base { valueStore[key] = value_; } // 避免undefined 等空值流失,先进行映射处理 - keyValuePairs.push([key, encodeRValue(value_)]); + keyValuePairs[keyValuePairs.length] = [key, encodeRValue(value_)]; } a.sendMessage("GM_setValues", [id, keyValuePairs]); return id; From fa26a1ba66747c58fc26136ff0448af55551819c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:15:49 +0900 Subject: [PATCH 043/106] =?UTF-8?q?=F0=9F=94=92=20bind=20page=20execution?= =?UTF-8?q?=20handles=20to=20navigation=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 31 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 14 +++++++-- src/app/service/service_worker/types.ts | 2 ++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 8168b8257..b138ed7f1 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1231,6 +1231,37 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); }); + it("rejects a stale URL when the browser omits documentId", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "url-bound-script", metadata: { grant: ["GM_getTab"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const initialSender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const load = await runtime.pageLoad(undefined, initialSender); + expect(load.ok).toBe(true); + if (!load.ok) return; + const handle = load.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(handle!, initialSender)).toBeDefined(); + + const navigatedSender = new SenderRuntime({ + url: "https://www.example.com/next", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + expect(runtime.resolvePageExecutionBinding(handle!, navigatedSender)).toBeUndefined(); + }); + it("content USER_SCRIPT 的 pageLoad 只轮换 content 绑定", async () => { const { runtime } = _createRuntimeContext(); const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 1e7578be7..cef58b4bc 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -371,7 +371,10 @@ export class RuntimeService { ): ServiceWorkerExecutionBinding { const source = sender.getSender(); const tabId = source?.tab?.id; - if (typeof tabId !== "number") throw new Error("page execution binding requires a tab"); + const url = source?.url; + if (typeof tabId !== "number" || typeof url !== "string" || url.length === 0) { + throw new Error("page execution binding requires a tab and URL"); + } // 每次 pageLoad 都签发新句柄和 runFlag;它们共同绑定当前文档的授权生命周期。 const handle = uuidv4(); const binding = { @@ -379,6 +382,7 @@ export class RuntimeService { uuid, envTag, runFlag: uuidv4(), + url, tabId, frameId: source?.frameId, documentId: source?.documentId, @@ -393,7 +397,13 @@ export class RuntimeService { resolvePageExecutionBinding(handle: string, sender: IGetSender): ServiceWorkerExecutionBinding | undefined { const binding = this.pageExecutionBindings.get(handle); const source = sender.getSender(); - if (!binding || !source?.tab || source.tab.id !== binding.tabId || source.frameId !== binding.frameId) + if ( + !binding || + !source?.tab || + source.tab.id !== binding.tabId || + source.frameId !== binding.frameId || + (binding.documentId === undefined && source.url !== binding.url) + ) return undefined; if (binding.documentId !== undefined && source.documentId !== binding.documentId) return undefined; return binding; diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index ab8cd169c..00397d794 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -60,6 +60,8 @@ export type ServiceWorkerExecutionBinding = { uuid: string; envTag: "it" | "ct"; runFlag: string; + /** The URL observed when this execution binding was issued. */ + url: string; tabId: number; frameId?: number; documentId?: string; From 86193c06f59f5d92b6dc95ff7b1a1a20d9097548 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:19:34 +0900 Subject: [PATCH 044/106] =?UTF-8?q?=F0=9F=94=92=20hide=20CAT=20conversatio?= =?UTF-8?q?n=20state=20behind=20private=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/gm_api/cat_agent.test.ts | 17 ++++++ src/app/service/content/gm_api/cat_agent.ts | 58 +++++++++---------- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/app/service/content/gm_api/cat_agent.test.ts b/src/app/service/content/gm_api/cat_agent.test.ts index 6bec37b49..18dfa728f 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -73,6 +73,23 @@ describe("ConversationInstance 命令机制", () => { expect(ownNames).not.toContain("gmConnect"); expect(ownNames).not.toContain("conv"); expect(ownNames).not.toContain("scriptUuid"); + expect(ownNames).not.toContain("toolHandlers"); + expect(ownNames).not.toContain("toolDefs"); + expect(ownNames).not.toContain("messageHistory"); + }); + + it("does not let public mutation replace private conversation state", async () => { + const { instance } = createEphemeralInstance(); + const exposed = instance as unknown as Record; + exposed.messageHistory = [{ role: "user", content: "forged" }]; + exposed.toolHandlers = new Map([["forged", vi.fn()]]); + exposed.toolDefs = [{ name: "forged", description: "forged", parameters: {} }]; + + await instance.chat("real"); + + const messages = await instance.getMessages(); + expect(messages[0]).toMatchObject({ role: "user", content: "real" }); + expect(messages).not.toContainEqual({ role: "user", content: "forged" }); }); it("内置 /new 命令清空消息历史", async () => { diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 06126d5cd..4c6911def 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -97,10 +97,10 @@ export class ConversationInstance { // 私有状态包含跨 context 的发送函数;用 private field 隐藏它,避免脚本读取或替换传输入口。 #state: ConversationPrivateState; - public toolHandlers: Map = new Map(); - public toolDefs: ToolDefinition[] = []; - public ephemeral: boolean; - public messageHistory: Array<{ + #toolHandlers: Map = new Map(); + #toolDefs: ToolDefinition[] = []; + #ephemeral: boolean; + #messageHistory: Array<{ role: MessageRole; content: MessageContent; toolCallId?: string; @@ -130,11 +130,11 @@ export class ConversationInstance { background: background || false, }; this.#state = state; - this.ephemeral = ephemeral || false; + this.#ephemeral = ephemeral || false; if (initialTools) { for (const tool of initialTools) { - this.toolHandlers.set(tool.name, tool.handler); - this.toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); + this.#toolHandlers.set(tool.name, tool.handler); + this.#toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); } } @@ -175,8 +175,8 @@ export class ConversationInstance { const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "user", content }); } // 通过 GM API connect 建立流式连接 @@ -194,9 +194,9 @@ export class ConversationInstance { if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; + connectParams.messages = this.#messageHistory; connectParams.system = state.systemPrompt; connectParams.modelId = state.conv.modelId; } @@ -207,8 +207,8 @@ export class ConversationInstance { // ephemeral 模式:中间轮次(带 tool calls)已在 processChat 内按 new_message 边界追加到内存历史, // 这里只需追加不含 tool calls 的最终回复(done 事件保证到达时已无待处理的 tool calls)。 - if (this.ephemeral) { - this.messageHistory.push({ role: "assistant", content: reply.content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "assistant", content: reply.content }); } return reply; @@ -243,8 +243,8 @@ export class ConversationInstance { const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "user", content }); } const connectParams: Record = { @@ -261,9 +261,9 @@ export class ConversationInstance { if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; + connectParams.messages = this.#messageHistory; connectParams.system = state.systemPrompt; connectParams.modelId = state.conv.modelId; } @@ -273,7 +273,7 @@ export class ConversationInstance { // chat 连接不会收到 sync 事件(sync 快照仅由 attach 的 SW 端发出), // 公开签名与 scriptcat.d.ts 保持一致:chatStream 只产出 StreamChunk // ephemeral 模式:包装 stream 以收集 assistant 消息到内存历史 - if (this.ephemeral) { + if (this.#ephemeral) { return this.processStreamEphemeral(conn, handlers) as AsyncIterable; } @@ -304,8 +304,8 @@ export class ConversationInstance { // 合并实例级别和调用级别的工具定义(调用级同名工具同时替换 schema 与 handler) protected mergeTools(callTools?: ChatOptions["tools"]) { - const toolDefs: ToolDefinition[] = [...this.toolDefs]; - const handlers = new Map(this.toolHandlers); + const toolDefs: ToolDefinition[] = [...this.#toolDefs]; + const handlers = new Map(this.#toolHandlers); for (const tool of callTools || []) { const definition = { name: tool.name, @@ -323,9 +323,9 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { const state = this.#state; - if (this.ephemeral) { + if (this.#ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 - return this.messageHistory.map((msg, idx) => ({ + return this.#messageHistory.map((msg, idx) => ({ id: `ephemeral-${idx}`, conversationId: state.conv.id, role: msg.role, @@ -348,8 +348,8 @@ export class ConversationInstance { // 清空对话消息历史 async clear(): Promise { - if (this.ephemeral) { - this.messageHistory = []; + if (this.#ephemeral) { + this.#messageHistory = []; return; } const state = this.#state; @@ -405,15 +405,15 @@ export class ConversationInstance { const finalContent = buildContent(content, blocks); const round = ordered.map(cloneToolCall); aggregate.push(...round); - if (this.ephemeral && record && (content || blocks.length || round.length)) { - this.messageHistory.push({ + if (this.#ephemeral && record && (content || blocks.length || round.length)) { + this.#messageHistory.push({ role: "assistant", content: finalContent, toolCalls: round.length ? round : undefined, }); for (const toolCall of round) { if (toolCall.result !== undefined) { - this.messageHistory.push({ + this.#messageHistory.push({ role: "tool", content: toolCall.result, toolCallId: toolCall.id, @@ -799,13 +799,13 @@ export class ConversationInstance { result: JSON.stringify({ error: "Tool call cancelled: stream ended before it completed" }), }); }); - this.messageHistory.push({ + this.#messageHistory.push({ role: "assistant", content: buildContent(text, blocks), toolCalls: finalized.length ? finalized : undefined, }); for (const toolCall of finalized) { - this.messageHistory.push({ + this.#messageHistory.push({ role: "tool", content: toolCall.result!, toolCallId: toolCall.id, From 0bc23a6c48a1a63152351b2f3a96bc97642744f4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:21:01 +0900 Subject: [PATCH 045/106] =?UTF-8?q?=F0=9F=94=92=20close=20GM=20XHR=20conne?= =?UTF-8?q?ctions=20on=20setup=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 32 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 3 ++ 2 files changed, 35 insertions(+) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 945df925c..c0b84ed51 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -177,4 +177,36 @@ describe("GM_xmlhttpRequest callback cleanup", () => { expect(onerror).toHaveBeenCalledTimes(1); expect(onloadend).toHaveBeenCalledTimes(1); }); + + it("disconnects an established connection when listener setup throws", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const connection = { + onMessage: vi.fn(() => { + throw new Error("listener setup failed"); + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("listener setup failed"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 32c7dd940..f009e88d5 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -765,6 +765,9 @@ export function GM_xmlhttpRequest( } as TXhrCallBackArg); } })().catch((error) => { + const pendingConnection = connect; + connect = null; + pendingConnection?.disconnect(true); if (reqDone) return; reqDone = true; const message = error instanceof Error ? error.message : `${error}`; From 35b6766621e32c4dcff798f00b5e877f37c25cd1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:22:46 +0900 Subject: [PATCH 046/106] =?UTF-8?q?=F0=9F=94=92=20avoid=20accessor=20execu?= =?UTF-8?q?tion=20in=20GM=20DOM=20attributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.ts | 28 ++++++++++++------- .../gm_api/related_target_lifecycle.test.ts | 19 ++++++++++++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index f5b29b929..e12ba06c3 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -905,16 +905,24 @@ export default class GMApi extends GM_Base { } // 控制传送参数,避免参数出现 non-json-selizable - const attrsCT = {} as Record; - const setAttr = {} as Record; - for (const [key, value] of Object.entries(attrs as Record)) { - if (typeof value === "string" || typeof value === "number") { - // 数字不是标准的 attribute value type, 但常见于实际使用 - attrsCT[key] = value; - } else { - // property setter for non attribute (e.g. Function, Symbol, boolean, etc) - // Function, Symbol 无法跨环境传递 - setAttr[key] = value; + const attrsCT = Native.objectCreate(null) as Record; + const setAttr = Native.objectCreate(null) as Record; + if (attrs !== null) { + const keys = Native.reflectOwnKeys(attrs); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(attrs, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + const value = descriptor.value; + if (typeof value === "string" || typeof value === "number") { + // 数字不是标准的 attribute value type, 但常见于实际使用 + attrsCT[key] = value; + } else { + // property setter for non attribute (e.g. Function, Symbol, boolean, etc) + // Function, Symbol 无法跨环境传递 + setAttr[key] = value; + } } } diff --git a/src/app/service/content/gm_api/related_target_lifecycle.test.ts b/src/app/service/content/gm_api/related_target_lifecycle.test.ts index e2af52e95..b3368382a 100644 --- a/src/app/service/content/gm_api/related_target_lifecycle.test.ts +++ b/src/app/service/content/gm_api/related_target_lifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ScriptEnvTag } from "@Packages/message/consts"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; @@ -72,4 +72,21 @@ describe("relatedTarget lifecycle across content runtime callers", () => { receiver.relatedTarget.clear(); } }); + + it("skips accessor attributes without executing their getter", () => { + const { api, sender, receiver } = createApiWithContentRuntime(); + const getter = vi.fn(() => "forged"); + const attrs = { id: "safe" } as Record; + Object.defineProperty(attrs, "secret", { enumerable: true, configurable: true, get: getter }); + + try { + const element = api.GM_addElement(api, "div", attrs); + expect(element?.id).toBe("safe"); + expect(element).not.toHaveProperty("secret"); + expect(getter).not.toHaveBeenCalled(); + } finally { + sender.relatedTarget.clear(); + receiver.relatedTarget.clear(); + } + }); }); From cb49bf8a3c954cfa9bfc91d98e8efe07e602bd51 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:24:51 +0900 Subject: [PATCH 047/106] =?UTF-8?q?=F0=9F=94=92=20copy=20GM=20menu=20optio?= =?UTF-8?q?ns=20without=20accessors?= 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 | 33 +++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 37 ++++++++++++++----- 2 files changed, 60 insertions(+), 10 deletions(-) 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 6768c66de..648e19daa 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -551,6 +551,39 @@ describe.concurrent("GM_menu", () => { expect(await retPromise).toEqual(123); }); + it.concurrent("注册菜单不会执行选项 getter", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_registerMenuCommand"]; + script.code = ` + let getterCalls = 0; + const options = { accessKey: "s" }; + Object.defineProperty(options, "secret", { enumerable: true, get() { getterCalls += 1; return "forged"; } }); + GM_registerMenuCommand("safe", () => {}, options); + return getterCalls; + `; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { sendMessage: mockSendMessage } as unknown as Message; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: mockMessage, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + exec.scriptFunc = compileScript(compileScriptCode(script)); + + await expect(exec.exec()).resolves.toBe(0); + expect(mockSendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + api: "GM_registerMenuCommand", + params: [expect.any(String), "safe", expect.objectContaining({ accessKey: "s" })], + }), + }) + ); + expect(mockSendMessage.mock.calls[0][0].data.params[2]).not.toHaveProperty("secret"); + }); + it.concurrent("取消注册菜单", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_registerMenuCommand", "GM_unregisterMenuCommand"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index e12ba06c3..00b15a511 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -770,15 +770,33 @@ export default class GMApi extends GM_Base { listener = undefined; } // 浅拷贝避免修改/共用参数 - const options: SWScriptMenuItemOption = ( - typeof options_or_accessKey === "string" - ? { accessKey: options_or_accessKey } - : options_or_accessKey - ? { ...options_or_accessKey, id: undefined, individual: undefined } // id不直接储存在options (id 影响 groupKey 操作) - : {} - ) as ScriptMenuItemOption; + const optionObject = typeof options_or_accessKey === "object" && options_or_accessKey !== null; + let options: SWScriptMenuItemOption; + let optionId: string | number | undefined; + let optionIndividual: boolean | undefined; + if (typeof options_or_accessKey === "string") { + options = { accessKey: options_or_accessKey }; + } else if (optionObject) { + const safeOptions = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(options_or_accessKey); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(options_or_accessKey, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + safeOptions[key] = descriptor.value; + } + optionId = safeOptions.id as string | number | undefined; + optionIndividual = safeOptions.individual as boolean | undefined; + // id不直接储存在options (id 影响 groupKey 操作) + safeOptions.id = undefined; + safeOptions.individual = undefined; + options = safeOptions as SWScriptMenuItemOption; + } else { + options = {}; + } const isSeparator = !listener && !name; - let isIndividual = typeof options_or_accessKey === "object" ? options_or_accessKey.individual : undefined; + let isIndividual = optionObject ? optionIndividual : undefined; if (isIndividual === undefined && isSeparator) { isIndividual = true; } @@ -797,8 +815,7 @@ export default class GMApi extends GM_Base { } else { options.mSeparator = false; } - let providedId: string | number | undefined = - typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined; + let providedId: string | number | undefined = optionObject ? optionId : undefined; if (providedId === undefined) providedId = ctx.menuIdCounter! += 1; // 如无指定,使用累计器id const ret = providedId! as TScriptMenuItemID; providedId = `t${providedId!}`; // 见 TScriptMenuItemID 注释 From ef3adf5d55f632892d5f12a889a0115c87f07474 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:27:16 +0900 Subject: [PATCH 048/106] =?UTF-8?q?=F0=9F=94=92=20copy=20GM=20XHR=20header?= =?UTF-8?q?s=20without=20accessors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 23 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 19 +++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index c0b84ed51..c4bca8065 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -209,4 +209,27 @@ describe("GM_xmlhttpRequest callback cleanup", () => { expect(onerror).toHaveBeenCalledTimes(1); expect(onloadend).toHaveBeenCalledTimes(1); }); + + it("does not execute accessor headers while preparing the request", async () => { + const getter = vi.fn(() => "forged"); + const headers = {} as Record; + Object.defineProperty(headers, "X-Hostile", { enumerable: true, configurable: true, get: getter }); + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest(api as any, { url: "https://example.com/data", headers }, false); + + await vi.waitFor(() => expect(api.connect).toHaveBeenCalled()); + expect(getter).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(api.connect.mock.calls[0][1][0].headers, "X-Hostile")).toBeUndefined(); + request.abort(); + }); }); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index f009e88d5..f06988b5d 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -207,11 +207,20 @@ export function GM_xmlhttpRequest( const urlPromiseLike = typeof details.url === "object" ? convObjectToURL(details.url) : details.url; const dataPromise = dataEncode(details.data); const headers = details.headers; + let requestHeaders: Record | undefined; + let requestCookie = details.cookie; if (headers) { - for (const key of Object.keys(headers)) { + requestHeaders = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(headers); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(headers, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; if (key.toLowerCase() === "cookie") { - details.cookie = headers[key]; - delete headers[key]; + requestCookie = descriptor.value as string; + } else { + requestHeaders[key] = descriptor.value as string; } } } @@ -224,8 +233,8 @@ export function GM_xmlhttpRequest( method: details.method, timeout: details.timeout, url: "", - headers: details.headers, - cookie: details.cookie, + headers: requestHeaders, + cookie: requestCookie, responseType: details.responseType, overrideMimeType: details.overrideMimeType, anonymous: details.anonymous, From 5acba1ba9fb64b0d010aeecb7c1be069e84677ce Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:29:53 +0900 Subject: [PATCH 049/106] =?UTF-8?q?=F0=9F=94=92=20copy=20GM=20tab=20option?= =?UTF-8?q?s=20without=20accessors?= 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 | 19 ++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 25 +++++++++++-------- 2 files changed, 34 insertions(+), 10 deletions(-) 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 648e19daa..f723d20f3 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1493,6 +1493,25 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); +describe("GM_openInTab DTO", () => { + it("does not execute accessor options", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_openInTab"]; + const getter = vi.fn(() => "forged"); + const options = { active: true } as Record; + Object.defineProperty(options, "secret", { enumerable: true, configurable: true, get: getter }); + const sendMessage = vi.fn().mockResolvedValue(1); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script); + + api.GM_openInTab(api, "https://example.com", options as never); + + expect(getter).not.toHaveBeenCalled(); + const sentOptions = sendMessage.mock.calls[0][0].data.params[1]; + expect(sentOptions.active).toBe(true); + expect(Object.getOwnPropertyDescriptor(sentOptions, "secret")).toBeUndefined(); + }); +}); + describe("@grant GM_download", () => { it("空 url 应触发 onerror 而不是发起下载(GM_download)", 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 00b15a511..4103c6473 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -60,6 +60,19 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; +const copyOwnEnumerableDataProperties = (value: object): Record => { + const result = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + result[key] = descriptor.value; + } + return result; +}; + // 回调表不暴露 Map 原型,避免页面改写 Map 方法后影响值更新确认。 const valueChangePromiseMap: Record void> = Object.create(null); @@ -777,15 +790,7 @@ export default class GMApi extends GM_Base { if (typeof options_or_accessKey === "string") { options = { accessKey: options_or_accessKey }; } else if (optionObject) { - const safeOptions = Native.objectCreate(null) as Record; - const keys = Native.reflectOwnKeys(options_or_accessKey); - for (let index = 0; index < keys.length; index += 1) { - const key = keys[index]; - if (typeof key !== "string") continue; - const descriptor = Native.objectGetOwnPropertyDescriptor(options_or_accessKey, key); - if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; - safeOptions[key] = descriptor.value; - } + const safeOptions = copyOwnEnumerableDataProperties(options_or_accessKey as object); optionId = safeOptions.id as string | number | undefined; optionIndividual = safeOptions.individual as boolean | undefined; // id不直接储存在options (id 影响 groupKey 操作) @@ -1512,7 +1517,7 @@ export default class GMApi extends GM_Base { if (typeof param === "boolean") { option.active = !param; // Greasemonkey 3.x loadInBackground } else if (param) { - option = { ...param } as GMTypes.OpenTabOptions; + option = copyOwnEnumerableDataProperties(param) as GMTypes.OpenTabOptions; } if (typeof option.active !== "boolean" && typeof option.loadInBackground === "boolean") { // TM 同时兼容 active 和 loadInBackground ( active 优先 ) From 13d6645a712464f044689178d2ea1f1ad3e907df Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:31:05 +0900 Subject: [PATCH 050/106] =?UTF-8?q?=F0=9F=94=92=20copy=20notification=20de?= =?UTF-8?q?tails=20without=20accessors?= 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 | 20 +++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) 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 f723d20f3..932a02b57 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1512,6 +1512,26 @@ describe("GM_openInTab DTO", () => { }); }); +describe("GM_notification DTO", () => { + it("does not execute accessor details", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_notification"]; + const getter = vi.fn(() => "forged"); + const details = { text: "safe" } as Record; + Object.defineProperty(details, "secret", { enumerable: true, configurable: true, get: getter }); + const sendMessage = vi.fn().mockResolvedValue("notification-id"); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script); + + api.GM_notification(api, details as never); + await Promise.resolve(); + + expect(getter).not.toHaveBeenCalled(); + const sentDetails = sendMessage.mock.calls[0][0].data.params[0]; + expect(sentDetails.text).toBe("safe"); + expect(Object.getOwnPropertyDescriptor(sentDetails, "secret")).toBeUndefined(); + }); +}); + describe("@grant GM_download", () => { it("空 url 应触发 onerror 而不是发起下载(GM_download)", 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 4103c6473..1b9b94da0 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1384,7 +1384,7 @@ export default class GMApi extends GM_Base { break; } } else { - data = Object.assign({}, detail); + data = copyOwnEnumerableDataProperties(detail) as GMTypes.NotificationDetails; data.ondone = data.ondone || ondone; } let click: GMTypes.NotificationOnClick; From 6bf76fd90c712c696da70ed208ec05a901e5f964 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:36:04 +0900 Subject: [PATCH 051/106] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20reduce=20USER=5FSC?= =?UTF-8?q?RIPT=20callback=20dispatch=20scans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/runtime.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index cef58b4bc..fff568d06 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -334,9 +334,10 @@ export class RuntimeService { continue; } let bindingMatches = false; - for (const binding of this.pageExecutionBindings.values()) { + for (const handle of entry.handles) { + const binding = this.pageExecutionBindings.get(handle); if ( - entry.handles.has(binding.handle) && + binding && ((targetUuid !== undefined && targetUuid === binding.uuid) || (targetStorageName !== undefined && targetStorageName === binding.storageName)) ) { From 714008ea8b3477ca64233bacb773346a13071cd5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:38:15 +0900 Subject: [PATCH 052/106] =?UTF-8?q?=F0=9F=94=92=20prune=20revoked=20USER?= =?UTF-8?q?=5FSCRIPT=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/runtime.test.ts | 4 ++++ src/app/service/service_worker/runtime.ts | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index b138ed7f1..51370e6c2 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1420,6 +1420,10 @@ describe("USER_SCRIPT native callbacks", () => { storageName: "unrelated-storage", }); expect(sendMessage).not.toHaveBeenCalled(); + + (runtime as any).revokePageBindingsForScript("content-script"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect((runtime as any).userScriptConnections.size).toBe(0); }); }); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index fff568d06..70d119832 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -358,6 +358,17 @@ export class RuntimeService { for (const [handle, binding] of this.pageExecutionBindings) { if (binding.uuid === uuid) this.pageExecutionBindings.delete(handle); } + for (const [key, entry] of this.userScriptConnections) { + // 脚本撤销后同步裁剪句柄集;没有任何有效句柄的端口必须关闭,避免残留授权接收器。 + for (const handle of entry.handles) { + const binding = this.pageExecutionBindings.get(handle); + if (!binding || binding.uuid === uuid) entry.handles.delete(handle); + } + if (entry.handles.size === 0) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } + } for (const [token, bootstrap] of this.userScriptBootstraps) { if (bootstrap.scripts.some((script) => script.uuid === uuid)) this.userScriptBootstraps.delete(token); } From 63b01b380ac31ee5a2b72d04d225c83fc072b2d8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:54:45 +0900 Subject: [PATCH 053/106] =?UTF-8?q?=F0=9F=94=92=20harden=20USER=5FSCRIPT?= =?UTF-8?q?=20reconnect=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.test.ts | 19 ++++ packages/message/server.ts | 4 +- .../content/user_script_connection.test.ts | 35 ++++++- .../service/content/user_script_connection.ts | 23 ++++- .../service/service_worker/runtime.test.ts | 27 ++++++ src/app/service/service_worker/runtime.ts | 89 +++++++++++++++--- src/content.ts | 92 +++++++++++-------- 7 files changed, 237 insertions(+), 52 deletions(-) diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index e10861fb6..7da2268ee 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -639,6 +639,25 @@ describe("Server", () => { expect(handler).toHaveBeenCalledWith({ api: "GM_log" }, expect.any(SenderRuntime)); expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: "ok" }); }); + + it("allows the native USER_SCRIPT reconnect request", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn().mockReturnValue({ bootstrapToken: "next-token" }); + serviceWorkerServer.on("runtime/reconnectUserScript", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle( + "runtime/reconnectUserScript", + undefined, + sendResponse, + sender, + "userScript" + ); + + expect(handler).toHaveBeenCalledWith(undefined, expect.any(SenderRuntime)); + expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: { bootstrapToken: "next-token" } }); + }); }); describe("Connect 功能测试", () => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 511451a73..70a34d28d 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -269,11 +269,11 @@ export class Server { } private isUserScriptActionAllowed(action: string, origin: MessageOrigin | undefined, isConnect: boolean): boolean { - // USER_SCRIPT 只应取得注册握手和 GM RPC;其他 serviceWorker API 仍只接受扩展通道。 + // USER_SCRIPT 只应取得注册握手、断线重连和 GM RPC;其他 serviceWorker API 仍只接受扩展通道。 if (this.prefix !== "serviceWorker" || origin !== "userScript") return true; return isConnect ? action === "runtime/registerUserScript" || action === "runtime/gmApi" - : action === "runtime/gmApi"; + : action === "runtime/gmApi" || action === "runtime/reconnectUserScript"; } } diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index 170e5fd2f..d1174e86e 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; -import { connectUserScriptChannel } from "./user_script_connection"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./user_script_connection"; const makeConnection = (): MessageConnect => ({ onMessage: vi.fn(), @@ -40,4 +40,37 @@ describe("connectUserScriptChannel", () => { await expect(connectUserScriptChannel(message, "bootstrap-token", vi.fn())).resolves.toBeUndefined(); expect(message.connect).not.toHaveBeenCalled(); }); + + it("reports remote disconnects so the caller can reconnect natively", async () => { + const connection = makeConnection(); + const onDisconnect = vi.fn(); + const message = { + sendMessage: vi.fn().mockResolvedValue(true), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn(), onDisconnect); + + expect(connection.onDisconnect).toHaveBeenCalledOnce(); + const disconnectHandler = (connection.onDisconnect as ReturnType).mock.calls[0][0] as ( + isSelfDisconnected: boolean + ) => void; + disconnectHandler(false); + expect(onDisconnect).toHaveBeenCalledWith(false); + }); + + it("accepts only a valid native reconnect token response", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: { bootstrapToken: "next-token" } }), + } as unknown as Message; + + await expect(requestUserScriptReconnect(message, "current-token")).resolves.toBe("next-token"); + expect(message.sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/reconnectUserScript", + data: { reconnectToken: "current-token" }, + }); + + (message.sendMessage as ReturnType).mockResolvedValue({ code: 0, data: {} }); + await expect(requestUserScriptReconnect(message, "current-token")).resolves.toBeUndefined(); + }); }); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index c11a2cd80..7aee3cf15 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -1,6 +1,12 @@ import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => void; +type UserScriptDisconnectHandler = (isSelfDisconnected: boolean) => void; + +type UserScriptReconnectResponse = { + code?: unknown; + data?: unknown; +}; /** * 先让 service worker 开启 USER_SCRIPT 监听,再建立连接;浏览器可能立即投递端口, @@ -9,7 +15,8 @@ type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => export async function connectUserScriptChannel( message: Message, bootstrapToken: string, - onPacket: UserScriptPacketHandler + onPacket: UserScriptPacketHandler, + onDisconnect?: UserScriptDisconnectHandler ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); if (enabled === false) return undefined; @@ -18,6 +25,20 @@ export async function connectUserScriptChannel( data: { world: "USER_SCRIPT", bootstrapToken }, }); connection.onMessage((packet) => onPacket(connection, packet)); + if (onDisconnect) connection.onDisconnect(onDisconnect); connection.sendMessage({ action: "userScript/bootstrap" }); return connection; } + +export async function requestUserScriptReconnect( + message: Message, + reconnectToken: string +): Promise { + const response = await message.sendMessage({ + action: "serviceWorker/runtime/reconnectUserScript", + data: { reconnectToken }, + }); + if (response?.code !== 0 || response.data === null || typeof response.data !== "object") return undefined; + const token = (response.data as { bootstrapToken?: unknown }).bootstrapToken; + return typeof token === "string" && token.length > 0 && token.length <= 256 ? token : undefined; +} diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 51370e6c2..51ba5b0be 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1421,6 +1421,33 @@ describe("USER_SCRIPT native callbacks", () => { }); expect(sendMessage).not.toHaveBeenCalled(); + const reconnect = runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ); + expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); + expect((runtime as any).userScriptBootstraps.size).toBe(1); + expect( + runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ) + ).toBeUndefined(); + (runtime as any).revokePageBindingsForScript("content-script"); expect(connection.disconnect).toHaveBeenCalledWith(true); expect((runtime as any).userScriptConnections.size).toBe(0); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 70d119832..b65af7f5b 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -131,6 +131,16 @@ export type TScriptsForTab = { scriptmenus: ScriptMenu[]; } | null; +type UserScriptSession = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + reconnectToken: string; + tabId: number; + frameId?: number; + documentId?: string; +}; + const bgScriptStorageNames = new Set(); // For Firefox, StorageArea.setAccessLevel is not implemented. @@ -149,17 +159,9 @@ export class RuntimeService { string, { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } >(); - private readonly userScriptBootstraps = new Map< - string, - { - scripts: TScriptInfo[]; - envInfo: GMInfoEnv; - extensionOrigin?: ExtensionOrigin; - tabId: number; - frameId?: number; - documentId?: string; - } - >(); + private readonly userScriptBootstraps = new Map(); + // 连接断开后保留当前文档的已验证资料,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 + private readonly userScriptSessions = new Map(); // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. private readonly pageLoadSequences = new Map(); @@ -192,6 +194,7 @@ export class RuntimeService { ) { entry.connection.disconnect(true); this.userScriptConnections.delete(key); + this.userScriptSessions.delete(key); } } } @@ -210,11 +213,15 @@ export class RuntimeService { if (entry.tabId === tabId) { entry.connection.disconnect(true); this.userScriptConnections.delete(key); + this.userScriptSessions.delete(key); } } for (const [token, bootstrap] of this.userScriptBootstraps) { if (bootstrap.tabId === tabId) this.userScriptBootstraps.delete(token); } + for (const [key, session] of this.userScriptSessions) { + if (session.tabId === tabId) this.userScriptSessions.delete(key); + } const prefix = `${tabId}:`; for (const key of this.pageLoadSequences.keys()) { if (key.startsWith(prefix)) this.pageLoadSequences.delete(key); @@ -279,10 +286,11 @@ export class RuntimeService { handles.add(handle); } if (handles.size === 0) return false; - this.userScriptBootstraps.delete(handshake.bootstrapToken); const frameId = source.frameId; const documentId = source.documentId; const key = this.userScriptConnectionKey(tabId, frameId, documentId); + this.userScriptSessions.set(key, bootstrap); + this.userScriptBootstraps.delete(handshake.bootstrapToken); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); const entry = { connection, handles, tabId, frameId, documentId }; @@ -309,6 +317,7 @@ export class RuntimeService { scripts: bootstrap.scripts, envInfo: bootstrap.envInfo, extensionOrigin: bootstrap.extensionOrigin, + reconnectToken: bootstrap.reconnectToken, }, }); } catch { @@ -318,6 +327,56 @@ export class RuntimeService { return true; } + reconnectUserScript(data: unknown, sender: IGetSender): { bootstrapToken: string } | undefined { + if (!sender.isType(GetSenderType.RUNTIME) || sender.getConnectOrigin?.() !== "userScript") { + return undefined; + } + if ( + data === null || + typeof data !== "object" || + Object.keys(data).length !== 1 || + typeof (data as { reconnectToken?: unknown }).reconnectToken !== "string" || + (data as { reconnectToken: string }).reconnectToken.length === 0 || + (data as { reconnectToken: string }).reconnectToken.length > 256 + ) { + return undefined; + } + const source = sender.getSender(); + const tabId = source?.tab?.id; + if (!source || typeof tabId !== "number") return undefined; + const key = this.userScriptConnectionKey(tabId, source.frameId, source.documentId); + const session = this.userScriptSessions.get(key); + if (!session || (data as { reconnectToken: string }).reconnectToken !== session.reconnectToken) return undefined; + for (const script of session.scripts) { + const handle = script.executionHandle; + const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; + if ( + !binding || + binding.envTag !== "ct" || + binding.tabId !== tabId || + binding.frameId !== source.frameId || + binding.documentId !== source.documentId + ) { + this.userScriptSessions.delete(key); + return undefined; + } + } + const bootstrapToken = uuidv4(); + const nextSession = { ...session, reconnectToken: uuidv4() }; + for (const [token, bootstrap] of this.userScriptBootstraps) { + if ( + bootstrap.tabId === session.tabId && + bootstrap.frameId === session.frameId && + bootstrap.documentId === session.documentId + ) { + this.userScriptBootstraps.delete(token); + } + } + this.userScriptSessions.set(key, nextSession); + this.userScriptBootstraps.set(bootstrapToken, nextSession); + return { bootstrapToken }; + } + private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { const dataRecord = typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; @@ -372,6 +431,9 @@ export class RuntimeService { for (const [token, bootstrap] of this.userScriptBootstraps) { if (bootstrap.scripts.some((script) => script.uuid === uuid)) this.userScriptBootstraps.delete(token); } + for (const [key, session] of this.userScriptSessions) { + if (session.scripts.some((script) => script.uuid === uuid)) this.userScriptSessions.delete(key); + } } private issuePageBinding( @@ -822,6 +884,7 @@ export class RuntimeService { this.group.on("pageLoad", this.pageLoad.bind(this)); this.group.on("pageShow", this.pageShow.bind(this)); this.group.on("registerUserScript", this.registerUserScriptConnection.bind(this)); + this.group.on("reconnectUserScript", this.reconnectUserScript.bind(this)); // 监听脚本开启 this.mq.subscribe("enableScripts", async (data) => { @@ -1134,6 +1197,7 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { this.pageExecutionBindings.clear(); + this.userScriptSessions.clear(); for (const [key, entry] of this.userScriptConnections) { entry.connection.disconnect(true); this.userScriptConnections.delete(key); @@ -1615,6 +1679,7 @@ export class RuntimeService { scripts: contentScriptList, envInfo: res.envInfo, extensionOrigin: getExtensionOrigin(), + reconnectToken: userScriptBootstrapToken, tabId, frameId, documentId: chromeSender.documentId, diff --git a/src/content.ts b/src/content.ts index cba8cb9ff..8eb1713f6 100644 --- a/src/content.ts +++ b/src/content.ts @@ -4,12 +4,12 @@ import { ExtensionMessage } from "@Packages/message/extension_message"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; -import type { Message } from "@Packages/message/types"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; -import { connectUserScriptChannel } from "./app/service/content/user_script_connection"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; import type { TScriptInfo } from "./app/repo/scripts"; import type { GMInfoEnv } from "./app/service/content/types"; import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./app/service/content/page_rpc"; @@ -39,44 +39,64 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const scriptExecutor = new ScriptExecutor(msg, domContentMsg, "serviceWorker"); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); runtime.contentInit(domServer, domMsg); + let reconnecting = false; + let reconnectToken: string | undefined; + const handleUserScriptPacket = (_connection: MessageConnect, packet: TMessage) => { + if (packet.action === "content/pageLoad") { + const packetData = packet.data as { + scripts?: TScriptInfo[]; + envInfo?: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + reconnectToken?: unknown; + }; + if (!packetData || !Array.isArray(packetData.scripts) || packetData.scripts.length === 0 || !packetData.envInfo) { + return; + } + for (let i = 0; i < packetData.scripts.length; i += 1) { + const script = packetData.scripts[i]; + if ( + !script || + typeof script !== "object" || + script.executionEnvTag !== scriptEnvTag || + typeof script.executionHandle !== "string" + ) { + return; + } + } + if (typeof packetData.reconnectToken === "string" && packetData.reconnectToken.length > 0) { + reconnectToken = packetData.reconnectToken; + } + setPageRpcExtensionOrigin(packetData.extensionOrigin); + runtime.startScripts(packetData.scripts, packetData.envInfo); + } else if (packet.action === "content/runtime/valueUpdate") { + scriptExecutor.valueUpdate(packet.data as any); + } else if (packet.action === "content/runtime/emitEvent") { + scriptExecutor.emitEvent(packet.data as any); + } + }; + const openUserScriptChannel = async (bootstrapToken: string): Promise => { + try { + await connectUserScriptChannel(msg, bootstrapToken, handleUserScriptPacket, (isSelfDisconnected) => { + if (isSelfDisconnected || reconnecting) return; + if (!reconnectToken) return; + reconnecting = true; + void requestUserScriptReconnect(msg, reconnectToken) + .then((nextToken) => (nextToken ? openUserScriptChannel(nextToken) : undefined)) + .catch((error) => logger.logger().debug("USER_SCRIPT reconnect failed", { error: String(error) })) + .finally(() => { + reconnecting = false; + }); + }); + } catch (error) { + logger.logger().debug("USER_SCRIPT channel failed", { error: String(error) }); + } + }; domServer.on( "pageLoad", (data: { bootstrapToken?: unknown; envInfo?: GMInfoEnv; extensionOrigin?: ExtensionOrigin }) => { if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; - void connectUserScriptChannel(msg, data.bootstrapToken, (_connection, packet) => { - if (packet.action === "content/pageLoad") { - const packetData = packet.data as { - scripts?: TScriptInfo[]; - envInfo?: GMInfoEnv; - extensionOrigin?: ExtensionOrigin; - }; - if ( - !packetData || - !Array.isArray(packetData.scripts) || - packetData.scripts.length === 0 || - !packetData.envInfo - ) { - return; - } - for (let i = 0; i < packetData.scripts.length; i += 1) { - const script = packetData.scripts[i]; - if ( - !script || - typeof script !== "object" || - script.executionEnvTag !== scriptEnvTag || - typeof script.executionHandle !== "string" - ) { - return; - } - } - setPageRpcExtensionOrigin(packetData.extensionOrigin); - runtime.startScripts(packetData.scripts, packetData.envInfo); - } else if (packet.action === "content/runtime/valueUpdate") { - scriptExecutor.valueUpdate(packet.data as any); - } else if (packet.action === "content/runtime/emitEvent") { - scriptExecutor.emitEvent(packet.data as any); - } - }); + reconnectToken = data.bootstrapToken; + void openUserScriptChannel(data.bootstrapToken); } ); runtime.init(); From 3a8c561ace8cd471ce4c2d6c567ad0859cdd4b59 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:10:11 +0900 Subject: [PATCH 054/106] =?UTF-8?q?=F0=9F=94=92=20validate=20inject=20page?= =?UTF-8?q?=20bootstrap=20DTOs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 1 + .../service/content/script_runtime.test.ts | 118 ++++++++++++++++++ src/app/service/content/script_runtime.ts | 87 ++++++++++++- 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index cd1c0942e..f3439aab9 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -94,6 +94,7 @@ export const Native = { ownFragment: new DocumentFragment(), objectCreate: nativeBind(Object.create, Object), objectAssign: nativeBind(Object.assign, Object), + arrayIsArray: nativeArrayIsArray, objectKeys: nativeBind(Object.keys, Object), objectHasOwn: nativeBind(Object.hasOwn, Object), objectDefineProperty: nativeBind(Object.defineProperty, Object), diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index 217399ffb..54ed41563 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -3,6 +3,7 @@ import type { Message } from "@Packages/message/types"; import type { Server } from "@Packages/message/server"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { ScriptRuntime } from "./script_runtime"; +import type { ScriptExecutor } from "./script_executor"; describe("ScriptRuntime DOM bridge", () => { it("rejects accessor attributes without executing their getters", () => { @@ -46,3 +47,120 @@ describe("ScriptRuntime DOM bridge", () => { expect(element.textContent).toBe("hello"); }); }); + +describe("ScriptRuntime inject page bootstrap", () => { + const makeServer = () => { + const handlers = new Map unknown>(); + const server = { + on: vi.fn((name: string, callback: (data: unknown) => unknown) => { + handlers.set(name, callback); + }), + } as unknown as Server; + return { handlers, server }; + }; + + const makeExecutor = () => ({ + checkEarlyStartScript: vi.fn(), + startScripts: vi.fn(), + emitEvent: vi.fn(), + valueUpdate: vi.fn(), + }); + + const makePageLoad = () => ({ + scripts: [ + { + uuid: "inject-script", + name: "Inject script", + flag: "inject-script-flag", + code: "", + metadata: { grant: [] }, + resource: {}, + value: {}, + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + }, + ], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + }); + + it("rejects pageLoad payloads with accessors before starting scripts", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + const scripts = pageLoad.scripts; + const getter = vi.fn(() => scripts); + Object.defineProperty(pageLoad, "scripts", { configurable: true, enumerable: true, get: getter }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects pageLoad payloads whose own-key enumeration throws", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = new Proxy(makePageLoad(), { + ownKeys() { + throw new Error("hostile enumeration"); + }, + }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects inject scripts without the current execution binding", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + Object.defineProperty(pageLoad.scripts[0], "executionHandle", { configurable: true, value: undefined }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("starts scripts only after validating and cloning the execution binding", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).toHaveBeenCalledOnce(); + const [scripts, envInfo] = executor.startScripts.mock.calls[0]; + expect(scripts).not.toBe(pageLoad.scripts); + expect(scripts[0]).toMatchObject({ + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + }); + expect(envInfo).toEqual(pageLoad.envInfo); + }); + + it("keeps the content pageLoad path on the native payload", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = { scripts: [], envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false } }; + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).toHaveBeenCalledWith(pageLoad.scripts, pageLoad.envInfo); + }); +}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index cd0434094..4183b3106 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -9,7 +9,84 @@ import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { type TExtensionEnv } from "../extension/extension_env"; import { RuntimeClient } from "../service_worker/client"; -import { customClone } from "./global"; +import { customClone, Native } from "./global"; + +const MAX_EXECUTION_TOKEN_LENGTH = 256; + +// Inject pageLoad crosses the page-visible bridge, so only a cloned DTO with a current broker binding may reach the executor. +const isRecord = (value: unknown): value is Record => { + if (value === null || typeof value !== "object" || Native.arrayIsArray(value)) return false; + const prototype = Native.objectGetPrototypeOf(value); + return prototype === null || Native.objectGetPrototypeOf(prototype) === null; +}; + +const isStringArray = (value: unknown): value is string[] => { + if (!Native.arrayIsArray(value)) return false; + for (let index = 0; index < value.length; index += 1) { + if (typeof value[index] !== "string") return false; + } + return true; +}; + +const isExecutionToken = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= MAX_EXECUTION_TOKEN_LENGTH; + +const isPageResourceMap = (value: unknown): boolean => { + if (!isRecord(value)) return false; + const keys = Native.objectKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + const resource = value[key]; + if (!isRecord(resource) || typeof resource.content !== "string" || typeof resource.contentType !== "string") { + return false; + } + if (resource.base64 !== undefined && typeof resource.base64 !== "string") return false; + } + return true; +}; + +const isInjectScriptInfo = (value: unknown): value is TScriptInfo => { + if (!isRecord(value)) return false; + if ( + typeof value.uuid !== "string" || + value.uuid.length === 0 || + typeof value.name !== "string" || + typeof value.flag !== "string" || + value.flag.length === 0 || + typeof value.code !== "string" || + !isRecord(value.metadata) || + !isRecord(value.value) || + !isPageResourceMap(value.resource) || + (value.requireCssResource !== undefined && !isPageResourceMap(value.requireCssResource)) || + !isExecutionToken(value.executionHandle) || + value.executionEnvTag !== "it" || + !isExecutionToken(value.executionRunFlag) + ) { + return false; + } + const metadataKeys = Native.objectKeys(value.metadata); + for (let index = 0; index < metadataKeys.length; index += 1) { + const key = metadataKeys[index]; + if (!isStringArray(value.metadata[key])) return false; + } + return true; +}; + +const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: GMInfoEnv } | undefined => { + const cloned = customClone(data); + if (!isRecord(cloned) || Native.objectKeys(cloned).length !== 2) return undefined; + if (!Native.objectHasOwn(cloned, "scripts") || !Native.objectHasOwn(cloned, "envInfo")) return undefined; + if (!Native.arrayIsArray(cloned.scripts) || cloned.scripts.length === 0) return undefined; + for (let index = 0; index < cloned.scripts.length; index += 1) { + if (!isInjectScriptInfo(cloned.scripts[index])) return undefined; + } + if (!isRecord(cloned.envInfo)) return undefined; + if (cloned.envInfo.sandboxMode !== "raw" || typeof cloned.envInfo.isIncognito !== "boolean") { + return undefined; + } + if (cloned.envInfo.userAgentData !== undefined && !isRecord(cloned.envInfo.userAgentData)) return undefined; + return { scripts: cloned.scripts, envInfo: cloned.envInfo as unknown as GMInfoEnv }; +}; export class ScriptRuntime { constructor( @@ -99,7 +176,13 @@ export class ScriptRuntime { }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - // 监听事件 + if (this.scripEnvTag === "it") { + const safeData = cloneInjectPageLoad(data); + if (!safeData) return; + this.startScripts(safeData.scripts, safeData.envInfo); + return; + } + // content/native channels already carry the service-worker response directly. this.startScripts(data.scripts, data.envInfo); }); From cf9ee7b8b4da65f3e837ac47201f952926094eee Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:16:47 +0900 Subject: [PATCH 055/106] =?UTF-8?q?=F0=9F=94=92=20validate=20inject=20runt?= =?UTF-8?q?ime=20callback=20DTOs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_runtime.test.ts | 42 +++++++++ src/app/service/content/script_runtime.ts | 91 +++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index 54ed41563..423ea8421 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -118,6 +118,48 @@ describe("ScriptRuntime inject page bootstrap", () => { expect(executor.startScripts).not.toHaveBeenCalled(); }); + it("rejects callback DTO accessors before entering the script context", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const getter = vi.fn(() => eventData.data); + Object.defineProperty(eventData, "data", { configurable: true, enumerable: true, get: getter }); + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("clones valid callback and value-update DTOs before dispatch", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const valueData = { + uuid: "script", + storageName: "script", + entries: [["key", [0, { value: 1 }], [2]]], + sender: { runFlag: "run", tabId: 3 }, + valueUpdated: true, + }; + + handlers.get("runtime/emitEvent")?.(eventData); + handlers.get("runtime/valueUpdate")?.(valueData); + + expect(executor.emitEvent).toHaveBeenCalledOnce(); + expect(executor.valueUpdate).toHaveBeenCalledOnce(); + expect(executor.emitEvent.mock.calls[0][0]).not.toBe(eventData); + expect(executor.valueUpdate.mock.calls[0][0]).not.toBe(valueData); + expect(executor.emitEvent.mock.calls[0][0]).toEqual(eventData); + expect(executor.valueUpdate.mock.calls[0][0]).toEqual(valueData); + }); + it("rejects inject scripts without the current execution binding", () => { const { handlers, server } = makeServer(); const executor = makeExecutor(); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 4183b3106..4711cb1b3 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -72,6 +72,85 @@ const isInjectScriptInfo = (value: unknown): value is TScriptInfo => { return true; }; +const hasOnlyKeys = (value: Record, required: readonly string[], optional: readonly string[] = []) => { + const keys = Native.objectKeys(value); + for (let index = 0; index < required.length; index += 1) { + if (!Native.objectHasOwn(value, required[index])) return false; + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + for (let keyIndex = 0; keyIndex < required.length; keyIndex += 1) { + if (required[keyIndex] === key) { + known = true; + break; + } + } + if (!known) { + for (let keyIndex = 0; keyIndex < optional.length; keyIndex += 1) { + if (optional[keyIndex] === key) { + known = true; + break; + } + } + } + if (!known) return false; + } + return true; +}; + +const isEncodedValue = (value: unknown): boolean => { + if (!Native.arrayIsArray(value)) return false; + if (value.length === 1) return value[0] === 1 || value[0] === 2; + return value.length === 2 && value[0] === 0; +}; + +const cloneInjectValueUpdate = (data: unknown): ValueUpdateDataEncoded | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["entries", "uuid", "storageName", "sender", "valueUpdated"], ["id"]) || + (cloned.id !== undefined && (typeof cloned.id !== "string" || cloned.id.length > MAX_EXECUTION_TOKEN_LENGTH)) || + typeof cloned.uuid !== "string" || + typeof cloned.storageName !== "string" || + typeof cloned.valueUpdated !== "boolean" || + !isRecord(cloned.sender) || + !hasOnlyKeys(cloned.sender, ["runFlag"], ["tabId"]) || + typeof cloned.sender.runFlag !== "string" || + (cloned.sender.tabId !== undefined && typeof cloned.sender.tabId !== "number") || + !Native.arrayIsArray(cloned.entries) + ) { + return undefined; + } + for (let index = 0; index < cloned.entries.length; index += 1) { + const entry = cloned.entries[index]; + if ( + !Native.arrayIsArray(entry) || + entry.length !== 3 || + typeof entry[0] !== "string" || + !isEncodedValue(entry[1]) || + !isEncodedValue(entry[2]) + ) { + return undefined; + } + } + return cloned as unknown as ValueUpdateDataEncoded; +}; + +const cloneInjectEmitEvent = (data: unknown): EmitEventRequest | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["uuid", "event", "eventId"], ["data"]) || + typeof cloned.uuid !== "string" || + typeof cloned.event !== "string" || + typeof cloned.eventId !== "string" + ) { + return undefined; + } + return cloned as unknown as EmitEventRequest; +}; + const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: GMInfoEnv } | undefined => { const cloned = customClone(data); if (!isRecord(cloned) || Native.objectKeys(cloned).length !== 2) return undefined; @@ -169,9 +248,21 @@ export class ScriptRuntime { init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { // 转发给脚本 + if (this.scripEnvTag === "it") { + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); + return; + } this.scriptExecutor.emitEvent(data); }); this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); + return; + } this.scriptExecutor.valueUpdate(data); }); From d2b2d84a68f1b7a9862279c79b11dc2d6e9e0d73 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:25:34 +0900 Subject: [PATCH 056/106] =?UTF-8?q?=F0=9F=94=92=20avoid=20duplicate=20USER?= =?UTF-8?q?=5FSCRIPT=20bootstrap=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/script_runtime.test.ts | 14 ++++++++++++++ src/app/service/content/script_runtime.ts | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index 423ea8421..69b80b6f6 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -194,6 +194,20 @@ describe("ScriptRuntime inject page bootstrap", () => { expect(envInfo).toEqual(pageLoad.envInfo); }); + it("does not execute the same native bootstrap twice after a USER_SCRIPT reconnect", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const first = makePageLoad(); + const replay = makePageLoad(); + handlers.get("pageLoad")?.(first); + handlers.get("pageLoad")?.(replay); + + expect(executor.startScripts).toHaveBeenCalledOnce(); + }); + it("keeps the content pageLoad path on the native payload", () => { const { handlers, server } = makeServer(); const executor = makeExecutor(); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 4711cb1b3..af3faa60d 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -168,6 +168,9 @@ const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: }; export class ScriptRuntime { + // USER_SCRIPT 重连会重放同一份 bootstrap;按服务端签发的句柄去重,导航换文档时句柄也会随之更换。 + private readonly startedScriptKeys = new Native.Set(); + constructor( private readonly scripEnvTag: ScriptEnvTag, private readonly server: Server, @@ -287,7 +290,19 @@ export class ScriptRuntime { } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { - this.scriptExecutor.startScripts(scripts, envInfo); + if (scripts.length === 0) { + this.scriptExecutor.startScripts(scripts, envInfo); + return; + } + const freshScripts: TScriptInfo[] = []; + for (let index = 0; index < scripts.length; index += 1) { + const script = scripts[index]; + const key = script.executionHandle || `${this.scripEnvTag}:${script.uuid}`; + if (this.startedScriptKeys.has(key)) continue; + this.startedScriptKeys.add(key); + freshScripts.push(script); + } + if (freshScripts.length > 0) this.scriptExecutor.startScripts(freshScripts, envInfo); } externalMessage() { From a64a1cba73a62bf38b6c202d5e88eb04f4b272ad Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:39:58 +0900 Subject: [PATCH 057/106] =?UTF-8?q?=F0=9F=94=92=20route=20MAIN=20privilege?= =?UTF-8?q?d=20traffic=20over=20native=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/repo/scripts.ts | 2 + src/app/service/content/external.ts | 10 +- src/app/service/content/script_runtime.ts | 87 ++++++++++------ src/app/service/content/scripting.test.ts | 7 ++ src/app/service/content/scripting.ts | 7 +- .../content/user_script_connection.test.ts | 15 +++ .../service/content/user_script_connection.ts | 6 +- .../service/service_worker/runtime.test.ts | 84 ++++++++++++++++ src/app/service/service_worker/runtime.ts | 99 +++++++++++++------ src/inject.ts | 77 ++++++++++++++- 10 files changed, 324 insertions(+), 70 deletions(-) diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index 27cdfbf24..d2065f773 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -160,6 +160,8 @@ export type TClientPageLoadInfo = envInfo: GMInfoEnv; /** 一次性令牌,供 USER_SCRIPT world 请求私有 bootstrap。 */ userScriptBootstrapToken?: string; + /** 一次性令牌,供 MAIN world 的 inject 环境请求私有 bootstrap。 */ + userScriptInjectBootstrapToken?: string; } | { ok: false }; diff --git a/src/app/service/content/external.ts b/src/app/service/content/external.ts index 2ff7aa223..8aa255266 100644 --- a/src/app/service/content/external.ts +++ b/src/app/service/content/external.ts @@ -14,10 +14,12 @@ const isExternalWhitelisted = (hostname: string) => { }; // 生成暴露给页面的 Scriptcat 外部接口 -const createScriptcatExpose = (msg: Message) => { +const createScriptcatExpose = (msg: Message, messagePrefix: string) => { const scriptExpose: App.ExternalScriptCat = { isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) { - sendMessage(msg, "scripting/script/isInstalled", { name, namespace }).then(callback); + sendMessage(msg, `${messagePrefix}/script/isInstalled`, { name, namespace }).then( + callback + ); }, }; return scriptExpose; @@ -63,7 +65,7 @@ const patchTampermonkeyIsInstalled = (external: any, scriptExpose: App.ExternalS }; // inject 环境 pageLoad 后执行:按白名单对页面注入 external 接口 -export const onInjectPageLoaded = (msg: Message) => { +export const onInjectPageLoaded = (msg: Message, messagePrefix = "scripting") => { const hostname = window.location.hostname; // 不在白名单则不对外暴露接口 @@ -73,7 +75,7 @@ export const onInjectPageLoaded = (msg: Message) => { const external: External = (window.external || (window.external = {} as External)) as External; // 创建 Scriptcat 暴露对象 - const scriptExpose = createScriptcatExpose(msg); + const scriptExpose = createScriptcatExpose(msg, messagePrefix); // 尝试设置 external.Scriptcat safeSetExternal(external, "Scriptcat", scriptExpose); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index af3faa60d..61926f985 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -151,9 +151,20 @@ const cloneInjectEmitEvent = (data: unknown): EmitEventRequest | undefined => { return cloned as unknown as EmitEventRequest; }; -const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: GMInfoEnv } | undefined => { +type InjectPageLoadData = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + reconnectToken?: string; +}; + +const cloneInjectPageLoad = (data: unknown): InjectPageLoadData | undefined => { const cloned = customClone(data); - if (!isRecord(cloned) || Native.objectKeys(cloned).length !== 2) return undefined; + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["scripts", "envInfo"], ["reconnectToken"]) || + (cloned.reconnectToken !== undefined && !isExecutionToken(cloned.reconnectToken)) + ) + return undefined; if (!Native.objectHasOwn(cloned, "scripts") || !Native.objectHasOwn(cloned, "envInfo")) return undefined; if (!Native.arrayIsArray(cloned.scripts) || cloned.scripts.length === 0) return undefined; for (let index = 0; index < cloned.scripts.length; index += 1) { @@ -164,7 +175,11 @@ const cloneInjectPageLoad = (data: unknown): { scripts: TScriptInfo[]; envInfo: return undefined; } if (cloned.envInfo.userAgentData !== undefined && !isRecord(cloned.envInfo.userAgentData)) return undefined; - return { scripts: cloned.scripts, envInfo: cloned.envInfo as unknown as GMInfoEnv }; + return { + scripts: cloned.scripts, + envInfo: cloned.envInfo as unknown as GMInfoEnv, + reconnectToken: cloned.reconnectToken as string | undefined, + }; }; export class ScriptRuntime { @@ -250,34 +265,14 @@ export class ScriptRuntime { init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { - // 转发给脚本 - if (this.scripEnvTag === "it") { - const safeData = cloneInjectEmitEvent(data); - if (!safeData) return; - this.scriptExecutor.emitEvent(safeData); - return; - } - this.scriptExecutor.emitEvent(data); + this.receiveEmitEvent(data); }); this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { - if (this.scripEnvTag === "it") { - const safeData = cloneInjectValueUpdate(data); - if (!safeData) return; - this.scriptExecutor.valueUpdate(safeData); - return; - } - this.scriptExecutor.valueUpdate(data); + this.receiveValueUpdate(data); }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - if (this.scripEnvTag === "it") { - const safeData = cloneInjectPageLoad(data); - if (!safeData) return; - this.startScripts(safeData.scripts, safeData.envInfo); - return; - } - // content/native channels already carry the service-worker response directly. - this.startScripts(data.scripts, data.envInfo); + this.receivePageLoad(data); }); // 用于 early-start 的扩充参数 @@ -305,7 +300,43 @@ export class ScriptRuntime { if (freshScripts.length > 0) this.scriptExecutor.startScripts(freshScripts, envInfo); } - externalMessage() { - onInjectPageLoaded(this.msg); + receivePageLoad(data: unknown): string | undefined { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectPageLoad(data); + if (!safeData) return undefined; + this.startScripts(safeData.scripts, safeData.envInfo); + return safeData.reconnectToken; + } + if (!isRecord(data) || !Native.objectHasOwn(data, "scripts") || !Native.objectHasOwn(data, "envInfo")) + return undefined; + const scripts = data.scripts; + const envInfo = data.envInfo; + if (!Native.arrayIsArray(scripts) || !isRecord(envInfo)) return undefined; + this.startScripts(scripts as TScriptInfo[], envInfo as unknown as GMInfoEnv); + return undefined; + } + + receiveEmitEvent(data: unknown): void { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); + return; + } + this.scriptExecutor.emitEvent(data as EmitEventRequest); + } + + receiveValueUpdate(data: unknown): void { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); + return; + } + this.scriptExecutor.valueUpdate(data as ValueUpdateDataEncoded); + } + + externalMessage(messagePrefix = "scripting", message: Message = this.msg) { + onInjectPageLoaded(message, messagePrefix); } } diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts index 2cf1c6329..4f341cd02 100644 --- a/src/app/service/content/scripting.test.ts +++ b/src/app/service/content/scripting.test.ts @@ -32,6 +32,7 @@ describe("ScriptingRuntime page bootstrap", () => { contentScriptList: [makeScript("content-script")], envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, userScriptBootstrapToken: "bootstrap-token", + userScriptInjectBootstrapToken: "inject-bootstrap-token", } as TClientPageLoadInfo); const senderToExt = makeSender(); const senderToContent = makeSender(); @@ -63,6 +64,12 @@ describe("ScriptingRuntime page bootstrap", () => { }), }) ); + expect(senderToInject.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/bootstrap", + data: { bootstrapToken: "inject-bootstrap-token" }, + }) + ); expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); }); }); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index caf88b916..dc0bc2e27 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -178,7 +178,7 @@ export default class ScriptingRuntime { // 向service_worker请求脚本列表及环境信息 client.pageLoad("it").then((o) => { if (!o.ok) return; - const { injectScriptList, envInfo, userScriptBootstrapToken } = o; + const { injectScriptList, envInfo, userScriptBootstrapToken, userScriptInjectBootstrapToken } = o; // 每次页面加载都废弃旧句柄,避免无 documentId 的浏览器复用上一文档的授权。 this.pageRpc.revokeAll(); const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => @@ -210,6 +210,11 @@ export default class ScriptingRuntime { }); } + if (typeof userScriptInjectBootstrapToken === "string" && userScriptInjectBootstrapToken.length > 0) { + const injectClient = new Client(this.senderToInject, "inject"); + injectClient.do("bootstrap", { bootstrapToken: userScriptInjectBootstrapToken }); + } + // 向页面 发送脚本列表及环境信息 if (preparedInjectScriptList.length) { const injectClient = new Client(this.senderToInject, "inject"); diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index d1174e86e..9cfc3ea8b 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -31,6 +31,21 @@ describe("connectUserScriptChannel", () => { expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); }); + it("preserves the MAIN world identity when opening the inject port", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(true), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "inject-bootstrap", vi.fn(), undefined, "MAIN"); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "MAIN", bootstrapToken: "inject-bootstrap" }, + }); + }); + it("does not open a port when the browser cannot enable USER_SCRIPT listeners", async () => { const message = { sendMessage: vi.fn().mockResolvedValue(false), diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index 7aee3cf15..6a4466341 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -7,6 +7,7 @@ type UserScriptReconnectResponse = { code?: unknown; data?: unknown; }; +type UserScriptWorld = "USER_SCRIPT" | "MAIN"; /** * 先让 service worker 开启 USER_SCRIPT 监听,再建立连接;浏览器可能立即投递端口, @@ -16,13 +17,14 @@ export async function connectUserScriptChannel( message: Message, bootstrapToken: string, onPacket: UserScriptPacketHandler, - onDisconnect?: UserScriptDisconnectHandler + onDisconnect?: UserScriptDisconnectHandler, + world: UserScriptWorld = "USER_SCRIPT" ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); if (enabled === false) return undefined; const connection = await message.connect({ action: "serviceWorker/runtime/registerUserScript", - data: { world: "USER_SCRIPT", bootstrapToken }, + data: { world, bootstrapToken }, }); connection.onMessage((packet) => onPacket(connection, packet)); if (onDisconnect) connection.onDisconnect(onDisconnect); diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 51ba5b0be..c751b49dc 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1348,6 +1348,90 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); describe("USER_SCRIPT native callbacks", () => { + it("issues a separate MAIN bootstrap and routes its private callbacks over the native port", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "inject-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + const contentScript = _createScriptRunResource( + _createMockScript({ uuid: "content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [contentScript], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-main", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sendMessage = vi.fn(); + const connection = { + onMessage: vi.fn(), + sendMessage, + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const connectionSender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-main" }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + expect(pageLoad.ok && pageLoad.userScriptInjectBootstrapToken).toEqual(expect.any(String)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptInjectBootstrapToken : undefined; + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken }, connectionSender)).toBe( + false + ); + expect(runtime.registerUserScriptConnection({ world: "MAIN", bootstrapToken }, connectionSender)).toBe(true); + + const contentConnection = { + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const contentSender = { ...connectionSender, getConnect: () => contentConnection }; + const contentBootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken: contentBootstrapToken }, + contentSender + ) + ).toBe(true); + expect((runtime as any).userScriptConnections.size).toBe(2); + + const bootstrapHandler = (connection.onMessage as ReturnType).mock.calls[0]?.[0] as + | ((packet: TMessage) => void) + | undefined; + bootstrapHandler?.({ action: "userScript/bootstrap" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/pageLoad", + data: expect.objectContaining({ scripts: expect.any(Array) }), + }) + ); + + sendMessage.mockClear(); + (runtime as any).sendUserScriptMessage(undefined, "runtime/emitEvent", { + uuid: "inject-script", + event: "click", + eventId: "1", + }); + expect(sendMessage).toHaveBeenCalledWith({ + action: "inject/runtime/emitEvent", + data: { uuid: "inject-script", event: "click", eventId: "1" }, + }); + }); + it("只向当前文档中声明了对应脚本或 storageName 的连接投递更新", async () => { const { runtime } = _createRuntimeContext(); const script = _createScriptRunResource( diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index b65af7f5b..6e48ab886 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -136,6 +136,7 @@ type UserScriptSession = { envInfo: GMInfoEnv; extensionOrigin?: ExtensionOrigin; reconnectToken: string; + envTag: "it" | "ct"; tabId: number; frameId?: number; documentId?: string; @@ -154,10 +155,17 @@ export class RuntimeService { private gmApi?: GMApi; // 句柄绑定到 tab/frame/document;页面导航、脚本变更或窗口关闭时必须整体撤销。 private readonly pageExecutionBindings = new Map(); - // USER_SCRIPT 连接只保存它获准使用的 content-world 句柄,回调按句柄再做一次归属匹配。 + // 原生 page/content 端口只保留各自签发的句柄,回调发送前再按该集合过滤一次。 private readonly userScriptConnections = new Map< string, - { connection: MessageConnect; handles: Set; tabId: number; frameId?: number; documentId?: string } + { + connection: MessageConnect; + handles: Set; + envTag: "it" | "ct"; + tabId: number; + frameId?: number; + documentId?: string; + } >(); private readonly userScriptBootstraps = new Map(); // 连接断开后保留当前文档的已验证资料,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 @@ -237,8 +245,13 @@ export class RuntimeService { return [key, sequence]; } - private userScriptConnectionKey(tabId: number, frameId?: number, documentId?: string): string { - return `${tabId}:${frameId ?? -1}:${documentId ?? ""}`; + private userScriptConnectionKey( + tabId: number, + frameId: number | undefined, + documentId: string | undefined, + envTag: "it" | "ct" + ): string { + return `${tabId}:${frameId ?? -1}:${documentId ?? ""}:${envTag}`; } /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ @@ -249,7 +262,6 @@ export class RuntimeService { const handshake = data as { world?: unknown; bootstrapToken?: unknown }; if ( Object.keys(data).length !== 2 || - handshake.world !== "USER_SCRIPT" || typeof handshake.bootstrapToken !== "string" || handshake.bootstrapToken.length === 0 || handshake.bootstrapToken.length > 256 @@ -269,6 +281,9 @@ export class RuntimeService { ) { return false; } + // bootstrap 令牌决定唯一可消费这些句柄的 world,调用方不能借握手字段改投其他环境。 + const expectedWorld = bootstrap.envTag === "it" ? "MAIN" : "USER_SCRIPT"; + if (handshake.world !== expectedWorld) return false; const handles = new Set(); for (const script of bootstrap.scripts) { const handle = script.executionHandle; @@ -276,7 +291,7 @@ export class RuntimeService { const binding = this.pageExecutionBindings.get(handle); if ( !binding || - binding.envTag !== "ct" || + binding.envTag !== bootstrap.envTag || binding.tabId !== tabId || binding.frameId !== source.frameId || binding.documentId !== source.documentId @@ -288,12 +303,12 @@ export class RuntimeService { if (handles.size === 0) return false; const frameId = source.frameId; const documentId = source.documentId; - const key = this.userScriptConnectionKey(tabId, frameId, documentId); + const key = this.userScriptConnectionKey(tabId, frameId, documentId, bootstrap.envTag); this.userScriptSessions.set(key, bootstrap); this.userScriptBootstraps.delete(handshake.bootstrapToken); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); - const entry = { connection, handles, tabId, frameId, documentId }; + const entry = { connection, handles, envTag: bootstrap.envTag, tabId, frameId, documentId }; this.userScriptConnections.set(key, entry); connection.onDisconnect(() => { if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); @@ -311,14 +326,15 @@ export class RuntimeService { } bootstrapped = true; try { + const pageLoadData = { + scripts: bootstrap.scripts, + envInfo: bootstrap.envInfo, + reconnectToken: bootstrap.reconnectToken, + ...(bootstrap.envTag === "ct" ? { extensionOrigin: bootstrap.extensionOrigin } : {}), + }; connection.sendMessage({ - action: "content/pageLoad", - data: { - scripts: bootstrap.scripts, - envInfo: bootstrap.envInfo, - extensionOrigin: bootstrap.extensionOrigin, - reconnectToken: bootstrap.reconnectToken, - }, + action: `${bootstrap.envTag === "it" ? "inject" : "content"}/pageLoad`, + data: pageLoadData, }); } catch { this.userScriptConnections.delete(key); @@ -344,15 +360,27 @@ export class RuntimeService { const source = sender.getSender(); const tabId = source?.tab?.id; if (!source || typeof tabId !== "number") return undefined; - const key = this.userScriptConnectionKey(tabId, source.frameId, source.documentId); - const session = this.userScriptSessions.get(key); - if (!session || (data as { reconnectToken: string }).reconnectToken !== session.reconnectToken) return undefined; + let key: string | undefined; + let session: UserScriptSession | undefined; + for (const [candidateKey, candidateSession] of this.userScriptSessions) { + if ( + candidateSession.tabId === tabId && + candidateSession.frameId === source.frameId && + candidateSession.documentId === source.documentId && + candidateSession.reconnectToken === (data as { reconnectToken: string }).reconnectToken + ) { + key = candidateKey; + session = candidateSession; + break; + } + } + if (!key || !session) return undefined; for (const script of session.scripts) { const handle = script.executionHandle; const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; if ( !binding || - binding.envTag !== "ct" || + binding.envTag !== session.envTag || binding.tabId !== tabId || binding.frameId !== source.frameId || binding.documentId !== source.documentId @@ -406,7 +434,7 @@ export class RuntimeService { } if (!bindingMatches) continue; try { - entry.connection.sendMessage({ action: `content/${action}`, data }); + entry.connection.sendMessage({ action: `${entry.envTag === "it" ? "inject" : "content"}/${action}`, data }); } catch { this.userScriptConnections.delete(key); } @@ -1673,17 +1701,25 @@ export class RuntimeService { const injectScriptList = data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"); const contentScriptList = prepareScripts(res.contentScriptList, "ct"); let userScriptBootstrapToken: string | undefined; - if (data?.envTag === "it" && contentScriptList.length > 0) { - userScriptBootstrapToken = uuidv4(); - this.userScriptBootstraps.set(userScriptBootstrapToken, { - scripts: contentScriptList, - envInfo: res.envInfo, - extensionOrigin: getExtensionOrigin(), - reconnectToken: userScriptBootstrapToken, - tabId, - frameId, - documentId: chromeSender.documentId, - }); + let userScriptInjectBootstrapToken: string | undefined; + if (data?.envTag === "it") { + const createBootstrap = (scripts: TScriptInfo[], envTag: "it" | "ct"): string | undefined => { + if (scripts.length === 0) return undefined; + const token = uuidv4(); + this.userScriptBootstraps.set(token, { + scripts, + envInfo: res.envInfo, + extensionOrigin: getExtensionOrigin(), + reconnectToken: token, + envTag, + tabId, + frameId, + documentId: chromeSender.documentId, + }); + return token; + }; + userScriptInjectBootstrapToken = createBootstrap(injectScriptList, "it"); + userScriptBootstrapToken = createBootstrap(contentScriptList, "ct"); } // 返回脚本资料,在页面加载 return { @@ -1692,6 +1728,7 @@ export class RuntimeService { contentScriptList: data?.envTag === "it" ? [] : contentScriptList, envInfo: res.envInfo, userScriptBootstrapToken, + userScriptInjectBootstrapToken, }; } else { // 没有脚本资料,不需要加载 diff --git a/src/inject.ts b/src/inject.ts index 878f2426c..f3fb02ba0 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -2,6 +2,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { PageMessage } from "@Packages/message/page_message"; +import { ExtensionMessage } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; @@ -9,17 +10,26 @@ import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; +import type { MessageConnect, TMessage } from "@Packages/message/types"; const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.inject; - const msg: Message = new PageMessage(eventFlag, "inject"); + const pageMsg: Message = new PageMessage(eventFlag, "inject"); + const nativeMsg: Message = new ExtensionMessage(false); + // 特权 GM RPC 使用浏览器标记的 USER_SCRIPT 来源;页面桥只保留 bootstrap 与 DOM 引用辅助。 + const canUseNativeChannel = + typeof chrome !== "undefined" && + typeof chrome.runtime?.connect === "function" && + typeof chrome.runtime?.sendMessage === "function"; + const msg: Message = canUseNativeChannel ? nativeMsg : pageMsg; // 初始化日志组件 const logger = new LoggerCore({ - writer: new MessageWriter(msg, "scripting/logger"), + writer: new MessageWriter(msg, canUseNativeChannel ? "serviceWorker/logger" : "scripting/logger"), consoleLevel: process.env.NODE_ENV === "development" ? "debug" : "none", // 只让日志在scripting环境中打印 labels: { env: "inject", href: window.location.href }, }); @@ -27,10 +37,69 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde logger.logger().debug("inject start"); const server = new Server("inject", msg); - const scriptExecutor = new ScriptExecutor(msg, new CustomEventMessage(eventFlag, true, ScriptEnvTag.content)); + const scriptExecutor = new ScriptExecutor( + msg, + new CustomEventMessage(eventFlag, true, ScriptEnvTag.content), + canUseNativeChannel ? "serviceWorker" : "scripting" + ); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); + const pageServer = canUseNativeChannel ? new Server("inject", pageMsg) : undefined; + let reconnecting = false; + let openingNative = false; + let nativeConnection: MessageConnect | undefined; + let reconnectToken: string | undefined; + + const handleNativePacket = (_connection: MessageConnect, packet: TMessage) => { + if (packet.action === "inject/pageLoad") { + const nextToken = runtime.receivePageLoad(packet.data); + if (nextToken) reconnectToken = nextToken; + } else if (packet.action === "inject/runtime/valueUpdate") { + runtime.receiveValueUpdate(packet.data); + } else if (packet.action === "inject/runtime/emitEvent") { + runtime.receiveEmitEvent(packet.data); + } + }; + + const openNativeChannel = async (bootstrapToken: string): Promise => { + if (openingNative || nativeConnection) return; + openingNative = true; + let connection: MessageConnect | undefined; + try { + connection = await connectUserScriptChannel( + nativeMsg, + bootstrapToken, + handleNativePacket, + (isSelfDisconnected) => { + if (nativeConnection === connection) nativeConnection = undefined; + if (isSelfDisconnected || reconnecting || !reconnectToken) return; + reconnecting = true; + void requestUserScriptReconnect(nativeMsg, reconnectToken) + .then((nextToken) => (nextToken ? openNativeChannel(nextToken) : undefined)) + .catch((error) => logger.logger().debug("MAIN USER_SCRIPT reconnect failed", { error: String(error) })) + .finally(() => { + reconnecting = false; + }); + }, + "MAIN" + ); + nativeConnection = connection; + } catch (error) { + logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); + } finally { + openingNative = false; + } + }; + + pageServer?.on("bootstrap", (data: { bootstrapToken?: unknown }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + reconnectToken = data.bootstrapToken; + void openNativeChannel(data.bootstrapToken); + }); + pageServer?.on("pageLoad", (data) => { + runtime.receivePageLoad(data); + }); runtime.init(); // inject环境,直接判断白名单,注入对外接口 - runtime.externalMessage(); + runtime.externalMessage("scripting", pageMsg); }); From 281e449a29c765c5f1030de4146f1df85c681ef3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:43:52 +0900 Subject: [PATCH 058/106] =?UTF-8?q?=F0=9F=94=92=20redact=20early-start=20p?= =?UTF-8?q?reload=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/exec_script.ts | 8 +++++ .../service/content/script_executor.test.ts | 8 +++++ src/app/service/content/utils.test.ts | 36 +++++++++++++++++++ src/app/service/content/utils.ts | 13 ++++++- 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index e0acb4c0c..aca46a871 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -96,6 +96,14 @@ export default class ExecScript { // 早期启动的脚本,处理GM API updateEarlyScriptGMInfo(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) { + if (scriptInfo) { + // 预注入事件可被页面观察,只携带空的用户值和配置;pageLoad 到达后再补回权威副本。 + this.scriptRes.value = scriptInfo.value; + this.scriptRes.config = scriptInfo.config; + this.scriptRes.metadata = scriptInfo.metadata; + this.scriptRes.resource = scriptInfo.resource; + this.scriptRes.requireCssResource = scriptInfo.requireCssResource; + } if (scriptInfo?.executionHandle && scriptInfo.executionEnvTag) { // early-start 先执行后取得绑定;此处补写同一绑定,使后续 RPC 与首次注册一致。 this.scriptRes.executionHandle = scriptInfo.executionHandle; diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 31dcd154c..d2da26d4f 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -110,12 +110,20 @@ describe("ScriptExecutor", () => { exec.updateEarlyScriptGMInfo(initEnvInfo, { ...initial, + value: { secret: "authoritative-value" }, + config: { + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }, executionHandle: "page-binding", executionEnvTag: "it", }); expect(exec.scriptRes.executionHandle).toBe("page-binding"); expect(exec.scriptRes.executionEnvTag).toBe("it"); + expect(exec.scriptRes.value).toEqual({ secret: "authoritative-value" }); + expect(exec.scriptRes.config).toEqual({ + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }); }); it("ignores a counterfeit mount and keeps listening for the genuine wrapper", () => { diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 0933854e1..e73da126c 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -793,6 +793,42 @@ describe("utils", () => { expect(testPerformance.addEventListener).not.toHaveBeenCalled(); }); + it.concurrent("does not expose stored values or user config in the observable preload event", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-private-uuid", + name: "Pre Inject Private Script", + namespace: "pre.inject.private", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: { secret: "stored-value" }, + config: { private: { secret: { title: "Private", description: "", index: 0, default: "secret" } } }, + flag: "pre-inject-private-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + }; + let detail: Record | undefined; + const testPerformance = { + dispatchEvent: vi.fn((event: Event) => { + detail = (event as CustomEvent).detail; + return false; + }), + addEventListener: vi.fn(), + }; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), {}, testPerformance); + + expect(detail?.scriptInfo.value).toEqual({}); + expect(detail?.scriptInfo.config).toBeUndefined(); + }); + it.concurrent("does not mount a regex-excluded early-start script", () => { const script: ScriptLoadInfo = { uuid: "pre-inject-excluded-uuid", diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index f8c779e11..23b165f1f 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -280,6 +280,17 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { return scriptInfo; }; +/** + * 预注入事件会经过页面可观察的 performance 通道;不要把用户值或配置放进它的 detail。 + * 资源仍需在脚本最早执行时可用,后续 pageLoad 会补回权威的值与配置。 + */ +export const trimPreInjectScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { + const scriptInfo = trimScriptInfo(script); + scriptInfo.value = {}; + scriptInfo.config = undefined; + return scriptInfo; +}; + /** * 将脚本函数编译为预注入脚本代码 */ @@ -291,7 +302,7 @@ export function compilePreInjectScript( const scriptEnvTag = isInjectIntoContent(script.metadata) ? ScriptEnvTag.content : ScriptEnvTag.inject; const eventNamePrefix = `evt${process.env.SC_RANDOM_KEY}.${scriptEnvTag}`; // 仅用于early-start初始化 const flag = `${script.flag}`; - const scriptInfo = trimScriptInfo(script); + const scriptInfo = trimPreInjectScriptInfo(script); const scriptInfoJSON = `${JSON.stringify(scriptInfo)}`; const scriptUrlPatterns = script.scriptUrlPatterns?.map(({ ruleType, ruleContent }) => ({ ruleType, ruleContent })); const urlCondition = scriptUrlPatterns From 50a08b2239b9dba6dfac7a21f51e86b1b41993d7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:47:24 +0900 Subject: [PATCH 059/106] =?UTF-8?q?=F0=9F=94=92=20settle=20XHR=20errors=20?= =?UTF-8?q?without=20broker=20loadend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 67 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 7 +- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index c4bca8065..16b095d12 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -16,6 +16,7 @@ describe("GM_xmlhttpRequest callback cleanup", () => { onDisconnect: vi.fn(), }; const onloadend = vi.fn(); + const onload = vi.fn(); const api = { isInvalidContext: () => false, connect: vi.fn().mockResolvedValue(connection), @@ -28,6 +29,7 @@ describe("GM_xmlhttpRequest callback cleanup", () => { onerror: () => { throw new Error("user callback failed"); }, + onload, onloadend, }, true @@ -50,6 +52,21 @@ describe("GM_xmlhttpRequest callback cleanup", () => { error: "network", }, }); + onMessage({ + code: 0, + action: "onload", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onload", + ok: false, + contentType: "text/plain", + }, + }); onMessage({ code: 0, action: "onloadend", @@ -66,6 +83,56 @@ describe("GM_xmlhttpRequest callback cleanup", () => { }, }); + await expect(request.retPromise).rejects.toBe("network"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onload).not.toHaveBeenCalled(); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("synthesizes loadend when an error has no terminal broker event", async () => { + let onMessage!: (message: any) => void; + const connection = { + onMessage: vi.fn((callback: (message: any) => void) => { + onMessage = callback; + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror: vi.fn(), + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(onMessage).toBeTypeOf("function")); + onMessage({ + code: 0, + action: "onerror", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onerror", + ok: false, + contentType: "text/plain", + error: "network", + }, + }); + await expect(request.retPromise).rejects.toBe("network"); expect(connection.disconnect).toHaveBeenCalledWith(true); expect(onloadend).toHaveBeenCalledTimes(1); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index f06988b5d..e24a7f113 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -539,6 +539,7 @@ export function GM_xmlhttpRequest( }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; let loadendCalled = false; + let loadCalled = false; const doLoadEnd = (data: TXhrCallBackArg) => { if (!loadendCalled) { loadendCalled = true; @@ -689,6 +690,8 @@ export function GM_xmlhttpRequest( break; } case "onload": + if (loadCalled || reqDone) break; + loadCalled = true; invokeXHRCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { @@ -744,8 +747,8 @@ export function GM_xmlhttpRequest( details.onerror, (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError ); - // 不要进行 refCleanup !要等待最后的 onloadend - // refCleanup?.(); + // 错误消息可能没有对应的 onloadend,补发一次以收尾并释放连接。 + scheduleSyntheticLoadEnd(); } break; case "onabort": From b84ccaf112c7a5c3c4a41656df0595ca68251eea Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:49:36 +0900 Subject: [PATCH 060/106] =?UTF-8?q?=F0=9F=94=92=20isolate=20protected=20GM?= =?UTF-8?q?=20facade=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.test.ts | 17 +++++++++++++++++ src/app/service/content/create_context.ts | 2 +- src/app/service/content/gm_api/gm_context.ts | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 8d6f723e9..b727499c9 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -316,6 +316,23 @@ describe("createContext: capability and lifecycle contract", () => { expect(context).not.toHaveProperty("grantSet"); }); + it("does not let page prototype pollution hide granted APIs", () => { + const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, "GM_getValue"); + try { + Object.defineProperty(Object.prototype, "GM_getValue", { + configurable: true, + value: true, + }); + + const context = createTestContext(["GM_getValue"]); + + expect(context.GM_getValue).toBeTypeOf("function"); + } finally { + if (descriptor) Object.defineProperty(Object.prototype, "GM_getValue", descriptor); + else Reflect.deleteProperty(Object.prototype, "GM_getValue"); + } + }); + it("creates collection instances from frozen captured-method subclasses", () => { const set = new Native.Set(["grant"]); const map = new Native.Map(); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 8b572ba14..75333db34 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -599,7 +599,7 @@ export const createProxyContext = ( const contextKeys = Native.objectKeys(context); for (let i = 0; i < contextKeys.length; i += 1) { const key = contextKeys[i]; - if (key in protect || key === "window") continue; + if (Native.objectHasOwn(protect, key) || key === "window") continue; mySandbox[key] = context[key]; // window以外 } diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 87d873357..d8ebc4a40 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -28,7 +28,7 @@ function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam m[m.length] = { fnKey, api, param }; } -export const protect: { [key: string]: any } = {}; +export const protect: { [key: string]: any } = Native.objectCreate(null); export default class GMContext { public static protected(value: any = undefined) { From cfb698c6ff92b40c213d70b0ef941722164e08d1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:00:42 +0900 Subject: [PATCH 061/106] =?UTF-8?q?=F0=9F=94=92=20keep=20service=20worker?= =?UTF-8?q?=20bridge=20bundle=20DOM-free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 6 ++++-- src/app/service/content/script_runtime.ts | 2 ++ src/inject.ts | 18 ++++++++++-------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index f3439aab9..e05fd5073 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -30,6 +30,8 @@ const nativeObjectFreeze = Object.freeze; const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const hasNativeStructuredClone = typeof structuredClone === "function"; +const nativeDocumentCreateElement = typeof Document === "undefined" ? undefined : Document.prototype.createElement; +const nativeOwnFragment = typeof DocumentFragment === "undefined" ? undefined : new DocumentFragment(); // Keep the captured methods on private subclasses. Instances can then be created // without reassigning every method, while the subclass prototypes remain outside @@ -90,8 +92,8 @@ export const Native = { structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, jsonStringify: nativeBind(JSON.stringify, JSON), jsonParse: nativeBind(JSON.parse, JSON), - createElement: Document.prototype.createElement, - ownFragment: new DocumentFragment(), + createElement: nativeDocumentCreateElement, + ownFragment: nativeOwnFragment, objectCreate: nativeBind(Object.create, Object), objectAssign: nativeBind(Object.assign, Object), arrayIsArray: nativeArrayIsArray, diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 61926f985..d06c1973f 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -274,6 +274,8 @@ export class ScriptRuntime { this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { this.receivePageLoad(data); }); + // Older MAIN worlds may receive a forward-compatible native bootstrap token but cannot open a runtime port. + this.server.on("bootstrap", () => undefined); // 用于 early-start 的扩充参数 const { inIncognitoContext } = this.extensionEnv || {}; diff --git a/src/inject.ts b/src/inject.ts index f3fb02ba0..b00e5257a 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -90,14 +90,16 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde } }; - pageServer?.on("bootstrap", (data: { bootstrapToken?: unknown }) => { - if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; - reconnectToken = data.bootstrapToken; - void openNativeChannel(data.bootstrapToken); - }); - pageServer?.on("pageLoad", (data) => { - runtime.receivePageLoad(data); - }); + if (pageServer) { + pageServer.on("bootstrap", (data: { bootstrapToken?: unknown }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + reconnectToken = data.bootstrapToken; + void openNativeChannel(data.bootstrapToken); + }); + pageServer.on("pageLoad", (data) => { + runtime.receivePageLoad(data); + }); + } runtime.init(); // inject环境,直接判断白名单,注入对外接口 From 8df243d904c16315e3f3f4f75587e683ba617500 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:09:01 +0900 Subject: [PATCH 062/106] =?UTF-8?q?=F0=9F=94=92=20validate=20USER=5FSCRIPT?= =?UTF-8?q?=20bridge=20DTOs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_runtime.test.ts | 50 +++++++++++++ src/app/service/content/script_runtime.ts | 72 ++++++++++++------- src/content.ts | 34 ++------- 3 files changed, 100 insertions(+), 56 deletions(-) diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index 69b80b6f6..d52b7ae59 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -219,4 +219,54 @@ describe("ScriptRuntime inject page bootstrap", () => { expect(executor.startScripts).toHaveBeenCalledWith(pageLoad.scripts, pageLoad.envInfo); }); + + it("rejects content pageLoad accessors before starting scripts", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = { + scripts: [ + { + uuid: "content-script", + name: "Content script", + flag: "content-script-flag", + code: "", + metadata: { grant: [] }, + resource: {}, + value: {}, + executionHandle: "content-binding", + executionEnvTag: "ct", + executionRunFlag: "content-run", + }, + ], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + }; + const scripts = pageLoad.scripts; + const getter = vi.fn(() => scripts); + Object.defineProperty(pageLoad, "scripts", { configurable: true, enumerable: true, get: getter }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects content callback DTO accessors before dispatch", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const eventPayload = eventData.data; + const getter = vi.fn(() => eventPayload); + Object.defineProperty(eventData, "data", { configurable: true, enumerable: true, get: getter }); + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index d06c1973f..72faa7d27 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -10,6 +10,7 @@ import type { CustomEventMessage } from "@Packages/message/custom_event_message" import { type TExtensionEnv } from "../extension/extension_env"; import { RuntimeClient } from "../service_worker/client"; import { customClone, Native } from "./global"; +import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./page_rpc"; const MAX_EXECUTION_TOKEN_LENGTH = 256; @@ -45,7 +46,7 @@ const isPageResourceMap = (value: unknown): boolean => { return true; }; -const isInjectScriptInfo = (value: unknown): value is TScriptInfo => { +const isPageScriptInfo = (value: unknown, envTag: "it" | "ct"): value is TScriptInfo => { if (!isRecord(value)) return false; if ( typeof value.uuid !== "string" || @@ -59,7 +60,7 @@ const isInjectScriptInfo = (value: unknown): value is TScriptInfo => { !isPageResourceMap(value.resource) || (value.requireCssResource !== undefined && !isPageResourceMap(value.requireCssResource)) || !isExecutionToken(value.executionHandle) || - value.executionEnvTag !== "it" || + value.executionEnvTag !== envTag || !isExecutionToken(value.executionRunFlag) ) { return false; @@ -157,31 +158,58 @@ type InjectPageLoadData = { reconnectToken?: string; }; -const cloneInjectPageLoad = (data: unknown): InjectPageLoadData | undefined => { +type PageLoadData = InjectPageLoadData & { + extensionOrigin?: ExtensionOrigin; +}; + +const isExtensionOrigin = (value: unknown): value is ExtensionOrigin => { + if (!isRecord(value) || !hasOnlyKeys(value, ["protocol", "hostname", "port"])) return false; + return ( + (value.protocol === "chrome-extension:" || value.protocol === "moz-extension:") && + typeof value.hostname === "string" && + value.hostname.length > 0 && + typeof value.port === "string" + ); +}; + +const clonePageLoad = ( + data: unknown, + envTag: "it" | "ct", + allowEmpty: boolean, + allowExtensionOrigin: boolean +): PageLoadData | undefined => { const cloned = customClone(data); if ( !isRecord(cloned) || - !hasOnlyKeys(cloned, ["scripts", "envInfo"], ["reconnectToken"]) || + !hasOnlyKeys( + cloned, + ["scripts", "envInfo"], + ["reconnectToken", ...(allowExtensionOrigin ? ["extensionOrigin"] : [])] + ) || (cloned.reconnectToken !== undefined && !isExecutionToken(cloned.reconnectToken)) ) return undefined; if (!Native.objectHasOwn(cloned, "scripts") || !Native.objectHasOwn(cloned, "envInfo")) return undefined; - if (!Native.arrayIsArray(cloned.scripts) || cloned.scripts.length === 0) return undefined; + if (!Native.arrayIsArray(cloned.scripts) || (!allowEmpty && cloned.scripts.length === 0)) return undefined; for (let index = 0; index < cloned.scripts.length; index += 1) { - if (!isInjectScriptInfo(cloned.scripts[index])) return undefined; + if (!isPageScriptInfo(cloned.scripts[index], envTag)) return undefined; } if (!isRecord(cloned.envInfo)) return undefined; if (cloned.envInfo.sandboxMode !== "raw" || typeof cloned.envInfo.isIncognito !== "boolean") { return undefined; } if (cloned.envInfo.userAgentData !== undefined && !isRecord(cloned.envInfo.userAgentData)) return undefined; + if (cloned.extensionOrigin !== undefined && !isExtensionOrigin(cloned.extensionOrigin)) return undefined; return { scripts: cloned.scripts, envInfo: cloned.envInfo as unknown as GMInfoEnv, reconnectToken: cloned.reconnectToken as string | undefined, + extensionOrigin: cloned.extensionOrigin as ExtensionOrigin | undefined, }; }; +const cloneInjectPageLoad = (data: unknown): InjectPageLoadData | undefined => clonePageLoad(data, "it", false, false); + export class ScriptRuntime { // USER_SCRIPT 重连会重放同一份 bootstrap;按服务端签发的句柄去重,导航换文档时句柄也会随之更换。 private readonly startedScriptKeys = new Native.Set(); @@ -309,33 +337,23 @@ export class ScriptRuntime { this.startScripts(safeData.scripts, safeData.envInfo); return safeData.reconnectToken; } - if (!isRecord(data) || !Native.objectHasOwn(data, "scripts") || !Native.objectHasOwn(data, "envInfo")) - return undefined; - const scripts = data.scripts; - const envInfo = data.envInfo; - if (!Native.arrayIsArray(scripts) || !isRecord(envInfo)) return undefined; - this.startScripts(scripts as TScriptInfo[], envInfo as unknown as GMInfoEnv); - return undefined; + const safeData = clonePageLoad(data, "ct", true, true); + if (!safeData) return undefined; + setPageRpcExtensionOrigin(safeData.extensionOrigin); + this.startScripts(safeData.scripts, safeData.envInfo); + return safeData.reconnectToken; } receiveEmitEvent(data: unknown): void { - if (this.scripEnvTag === "it") { - const safeData = cloneInjectEmitEvent(data); - if (!safeData) return; - this.scriptExecutor.emitEvent(safeData); - return; - } - this.scriptExecutor.emitEvent(data as EmitEventRequest); + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); } receiveValueUpdate(data: unknown): void { - if (this.scripEnvTag === "it") { - const safeData = cloneInjectValueUpdate(data); - if (!safeData) return; - this.scriptExecutor.valueUpdate(safeData); - return; - } - this.scriptExecutor.valueUpdate(data as ValueUpdateDataEncoded); + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); } externalMessage(messagePrefix = "scripting", message: Message = this.msg) { diff --git a/src/content.ts b/src/content.ts index 8eb1713f6..3f68af55c 100644 --- a/src/content.ts +++ b/src/content.ts @@ -10,9 +10,8 @@ import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; -import type { TScriptInfo } from "./app/repo/scripts"; import type { GMInfoEnv } from "./app/service/content/types"; -import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./app/service/content/page_rpc"; +import type { ExtensionOrigin } from "./app/service/content/page_rpc"; const messageFlag = process.env.SC_RANDOM_KEY!; @@ -43,35 +42,12 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde let reconnectToken: string | undefined; const handleUserScriptPacket = (_connection: MessageConnect, packet: TMessage) => { if (packet.action === "content/pageLoad") { - const packetData = packet.data as { - scripts?: TScriptInfo[]; - envInfo?: GMInfoEnv; - extensionOrigin?: ExtensionOrigin; - reconnectToken?: unknown; - }; - if (!packetData || !Array.isArray(packetData.scripts) || packetData.scripts.length === 0 || !packetData.envInfo) { - return; - } - for (let i = 0; i < packetData.scripts.length; i += 1) { - const script = packetData.scripts[i]; - if ( - !script || - typeof script !== "object" || - script.executionEnvTag !== scriptEnvTag || - typeof script.executionHandle !== "string" - ) { - return; - } - } - if (typeof packetData.reconnectToken === "string" && packetData.reconnectToken.length > 0) { - reconnectToken = packetData.reconnectToken; - } - setPageRpcExtensionOrigin(packetData.extensionOrigin); - runtime.startScripts(packetData.scripts, packetData.envInfo); + const nextToken = runtime.receivePageLoad(packet.data); + if (nextToken) reconnectToken = nextToken; } else if (packet.action === "content/runtime/valueUpdate") { - scriptExecutor.valueUpdate(packet.data as any); + runtime.receiveValueUpdate(packet.data); } else if (packet.action === "content/runtime/emitEvent") { - scriptExecutor.emitEvent(packet.data as any); + runtime.receiveEmitEvent(packet.data); } }; const openUserScriptChannel = async (bootstrapToken: string): Promise => { From beb742c1f14f503b5dcbac5021d9ef03e8ef4294 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:14:37 +0900 Subject: [PATCH 063/106] =?UTF-8?q?=F0=9F=94=92=20bind=20CAT=20tasks=20to?= =?UTF-8?q?=20script=20owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/agent/core/types.ts | 2 + src/app/service/agent/service_worker/agent.ts | 4 +- .../agent/service_worker/task_service.test.ts | 51 ++++++++++++++++++ .../agent/service_worker/task_service.ts | 52 ++++++++++++++----- .../service_worker/gm_api/gm_agent_task.ts | 2 +- 5 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index c2f197d5e..408bd8139 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -640,6 +640,8 @@ export type MCPApiRequest = /** 定时任务基础字段(两种模式共用) */ type AgentTaskBase = { id: string; + /** ScriptCat API owner; absent on tasks created by the extension UI or older records. */ + ownerScriptUuid?: string; /** Immutable identity for this incarnation of the task ID. */ generation?: string; /** Optimistic-concurrency version. */ diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 4ef1420f7..68afc9d84 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -352,8 +352,8 @@ export class AgentService { } // 处理定时任务 API 请求,供 GMApi 调用 - async handleAgentTaskApi(params: AgentTaskApiRequest) { - return this.agentTaskService.handleAgentTask(params); + async handleAgentTaskApi(params: AgentTaskApiRequest, ownerScriptUuid?: string) { + return this.agentTaskService.handleAgentTask(params, ownerScriptUuid); } // 处理 CAT.agent.model API 请求,委托给 AgentModelService diff --git a/src/app/service/agent/service_worker/task_service.test.ts b/src/app/service/agent/service_worker/task_service.test.ts index 58e62a272..5c120b528 100644 --- a/src/app/service/agent/service_worker/task_service.test.ts +++ b/src/app/service/agent/service_worker/task_service.test.ts @@ -128,6 +128,7 @@ describe("AgentTaskService 任务生命周期", () => { function createMutationService() { const current = { id: "task-cas", + ownerScriptUuid: "script-a", generation: "generation-current", revision: 3, name: "current", @@ -142,6 +143,7 @@ describe("AgentTaskService 任务生命周期", () => { } as const; const taskRepo = { getTask: vi.fn().mockResolvedValue(current), + listTasks: vi.fn().mockResolvedValue([current]), createTask: vi.fn(async (candidate: any) => candidate), saveTask: vi.fn(async (candidate: any) => { if (candidate.generation !== current.generation || candidate.revision !== current.revision) { @@ -195,6 +197,55 @@ describe("AgentTaskService 任务生命周期", () => { expect(taskRepo.saveTask).toHaveBeenCalledWith(expect.objectContaining({ revision: 2 })); }); + it("脚本创建的任务只允许同一脚本读取和修改", async () => { + const { service, taskRepo, scheduler, current } = createMutationService(); + const other = { ...current, id: "task-other", ownerScriptUuid: "script-b" }; + taskRepo.listTasks.mockResolvedValue([current, other]); + + await expect(service.handleAgentTask({ action: "list" }, "script-a")).resolves.toEqual([current]); + await expect(service.handleAgentTask({ action: "get", id: current.id }, "script-b")).rejects.toThrow( + "Task not found" + ); + await expect( + service.handleAgentTask( + { + action: "update", + id: current.id, + generation: current.generation, + revision: current.revision, + task: { name: "forged edit" }, + }, + "script-b" + ) + ).rejects.toThrow("Task not found"); + await expect(service.handleAgentTask({ action: "runNow", id: current.id }, "script-b")).rejects.toThrow( + "Task not found" + ); + expect(scheduler.executeTask).not.toHaveBeenCalled(); + }); + + it("脚本创建的任务绑定创建者身份而不是请求体伪造的身份", async () => { + const { service, taskRepo } = createMutationService(); + + await service.handleAgentTask( + { + action: "create", + task: { + name: "owned task", + mode: "internal", + crontab: "0 9 * * *", + prompt: "hello", + enabled: true, + notify: false, + ownerScriptUuid: "script-b", + }, + } as any, + "script-a" + ); + + expect(taskRepo.createTask).toHaveBeenCalledWith(expect.objectContaining({ ownerScriptUuid: "script-a" })); + }); + it("delete 应先取消活动执行并使用客户端版本删除", async () => { const { service, taskRepo, scheduler } = createMutationService(); diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index 425787c20..184354286 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -265,17 +265,38 @@ export class AgentTaskService { }; } + private taskBelongsTo(task: AgentTask, ownerScriptUuid: string): boolean { + return ( + task.ownerScriptUuid === ownerScriptUuid || + (task.ownerScriptUuid === undefined && task.mode === "event" && task.sourceScriptUuid === ownerScriptUuid) + ); + } + + private assertTaskAccess(task: AgentTask | undefined, ownerScriptUuid: string | undefined): AgentTask { + if (!task || (ownerScriptUuid !== undefined && !this.taskBelongsTo(task, ownerScriptUuid))) { + throw new Error("Task not found"); + } + return task; + } + // 处理定时任务 CRUD 及 run 操作 - async handleAgentTask(params: AgentTaskApiRequest): Promise { + async handleAgentTask(params: AgentTaskApiRequest, ownerScriptUuid?: string): Promise { switch (params.action) { - case "list": - return this.taskRepo.listTasks(); - case "get": - return this.taskRepo.getTask(params.id); + case "list": { + const tasks = await this.taskRepo.listTasks(); + return ownerScriptUuid === undefined + ? tasks + : tasks.filter((task) => this.taskBelongsTo(task, ownerScriptUuid)); + } + case "get": { + const task = await this.taskRepo.getTask(params.id); + return this.assertTaskAccess(task, ownerScriptUuid); + } case "create": { const now = Date.now(); const task = { ...params.task, + ownerScriptUuid, id: uuidv4(), createtime: now, updatetime: now, @@ -300,11 +321,11 @@ export class AgentTaskService { return this.taskRepo.createTask(task); } case "update": { - const existing = await this.taskRepo.getTask(params.id); - if (!existing) throw new Error("Task not found"); + const existing = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); const updated = { ...existing, ...params.task, + ownerScriptUuid: existing.ownerScriptUuid ?? ownerScriptUuid, id: params.id, generation: params.generation, revision: params.revision, @@ -332,17 +353,17 @@ export class AgentTaskService { return this.taskRepo.saveTask(updated); } case "delete": { + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); // 先中止正在运行的执行,再清理元数据/运行记录:cancelTask 是同步的 abort(),必须最先 // 发生,否则被删除的任务会在 removeTask(含 run-history 清理)完成前继续调用 LLM/工具/ // 产生外部副作用;若 removeTask 之后才 cancel,一旦 removeTask 因清理失败而抛出, // cancelTask 根本不会被调用,执行也就永远不会被中止 - this.taskScheduler?.cancelTask(params.id); + this.taskScheduler?.cancelTask(task.id); await this.taskRepo.removeTask(params.id, params.generation, params.revision); return true; } case "enable": { - const task = await this.taskRepo.getTask(params.id); - if (!task) throw new Error("Task not found"); + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); const updated = { ...task, enabled: params.enabled, @@ -361,19 +382,22 @@ export class AgentTaskService { return this.taskRepo.saveTask(updated); } case "runNow": { - const task = await this.taskRepo.getTask(params.id); - if (!task) throw new Error("Task not found"); + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); // 不 await,立即返回 const now = Date.now(); const claimScheduled = Boolean(task.enabled && task.nextruntime && task.nextruntime <= now); this.taskScheduler?.executeTask(task, claimScheduled, now).catch(() => {}); return true; } - case "listRuns": + case "listRuns": { + this.assertTaskAccess(await this.taskRepo.getTask(params.taskId), ownerScriptUuid); return this.taskRunRepo.listRuns(params.taskId, params.limit); - case "clearRuns": + } + case "clearRuns": { + this.assertTaskAccess(await this.taskRepo.getTask(params.taskId), ownerScriptUuid); await this.taskRunRepo.clearRuns(params.taskId); return true; + } default: throw new Error(`Unknown agentTask action: ${(params as any).action}`); } diff --git a/src/app/service/service_worker/gm_api/gm_agent_task.ts b/src/app/service/service_worker/gm_api/gm_agent_task.ts index 7de5a2880..1d942c05c 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_task.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_task.ts @@ -36,7 +36,7 @@ class GMAgentTaskApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleAgentTaskApi(request.params[0]); + return this.agentService.handleAgentTaskApi(request.params[0], request.script.uuid); } } From e2738c98ad7e4a24c1bb646a57e8e734056b05e1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:30:04 +0900 Subject: [PATCH 064/106] =?UTF-8?q?=F0=9F=94=92=20bind=20CAT=20conversatio?= =?UTF-8?q?ns=20to=20script=20owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/agent/core/types.ts | 5 +- src/app/service/agent/service_worker/agent.ts | 4 +- .../background_session_manager.test.ts | 45 ++++++++++ .../background_session_manager.ts | 14 ++- .../service/agent/service_worker/chat.test.ts | 86 ++++++++++++++++++- .../agent/service_worker/chat_service.ts | 55 +++++++++--- .../service/service_worker/gm_api/gm_agent.ts | 12 ++- .../service_worker/gm_api/gm_api.test.ts | 49 +++++++++++ 8 files changed, 251 insertions(+), 19 deletions(-) create mode 100644 src/app/service/agent/service_worker/background_session_manager.test.ts diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index 408bd8139..4f2ee5cc0 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -22,6 +22,8 @@ export type MessageContent = string | ContentBlock[]; export type Conversation = { id: string; + /** ScriptCat API owner; absent on conversations created by the extension UI or older records. */ + ownerScriptUuid?: string; /** Immutable identity for this incarnation of an ID. Filled when legacy records are loaded. */ generation?: string; /** Optimistic-concurrency version. Filled when legacy records are loaded. */ @@ -735,5 +737,6 @@ export type ConversationApiRequest = generation?: string; messageIds: string[]; preserveAttachmentIds?: string[]; + scriptUuid?: string; } - | { action: "delete"; conversationId: string; generation: string; revision?: number }; + | { action: "delete"; conversationId: string; generation: string; revision?: number; scriptUuid?: string }; diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 68afc9d84..507e694f9 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -386,7 +386,7 @@ export class AgentService { // 附加到后台运行会话,供 GMApi 调用 async handleAttachToConversationFromGmApi( - params: { conversationId: string; generation?: string }, + params: { conversationId: string; generation?: string; scriptUuid: string }, sender: IGetSender ) { return this.handleAttachToConversation(params, sender); @@ -399,7 +399,7 @@ export class AgentService { // 附加到后台运行中的会话(委托给 BackgroundSessionManager) private async handleAttachToConversation( - params: { conversationId: string; generation?: string }, + params: { conversationId: string; generation?: string; scriptUuid?: string }, sender: IGetSender ) { return this.bgSessionManager.handleAttach(params, sender); diff --git a/src/app/service/agent/service_worker/background_session_manager.test.ts b/src/app/service/agent/service_worker/background_session_manager.test.ts new file mode 100644 index 000000000..bab0570ca --- /dev/null +++ b/src/app/service/agent/service_worker/background_session_manager.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { BackgroundSessionManager, type RunningConversation } from "./background_session_manager"; + +function createSender() { + const sentMessages: any[] = []; + const connection = { + sendMessage: (message: any) => sentMessages.push(message), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + return { + sender: { + isType: (type: any) => type === 1, + getConnect: () => connection, + } as any, + sentMessages, + }; +} + +describe("BackgroundSessionManager script ownership", () => { + it("does not attach a script to another script's running conversation", async () => { + const manager = new BackgroundSessionManager(); + const rc: RunningConversation = { + conversationId: "conv-owned", + generation: "gen-a", + ownerScriptUuid: "script-a", + abortController: new AbortController(), + listeners: new Set(), + streamingState: { content: "secret", thinking: "", toolCalls: [] }, + askResolvers: new Map(), + tasks: [], + status: "running" as const, + }; + manager.set(rc.conversationId, rc); + const { sender, sentMessages } = createSender(); + + await manager.handleAttach( + { conversationId: rc.conversationId, generation: rc.generation, scriptUuid: "script-b" }, + sender + ); + + expect(sentMessages).toContainEqual({ action: "event", data: { type: "sync", tasks: [], status: "done" } }); + expect(rc.listeners.size).toBe(0); + }); +}); diff --git a/src/app/service/agent/service_worker/background_session_manager.ts b/src/app/service/agent/service_worker/background_session_manager.ts index 1378502fa..a2c385e23 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -10,6 +10,8 @@ export type ListenerEntry = { // 后台运行会话状态 export type RunningConversation = { conversationId: string; + /** ScriptCat API owner; absent for conversations started by the extension UI. */ + ownerScriptUuid?: string; // 该次运行绑定的会话 generation;attach() 的调用方必须持有同一 generation 才允许附加, // 否则会静默观察到删除重建后无关的新一代会话 generation: string; @@ -191,7 +193,10 @@ export class BackgroundSessionManager { } // 附加 UI 连接到后台运行中的会话(同步快照 + listener + askUser resolver + stop) - async handleAttach(params: { conversationId: string; generation?: string }, sender: IGetSender): Promise { + async handleAttach( + params: { conversationId: string; generation?: string; scriptUuid?: string }, + sender: IGetSender + ): Promise { if (!sender.isType(GetSenderType.CONNECT)) { throw new Error("attachToConversation requires connect mode"); } @@ -209,6 +214,13 @@ export class BackgroundSessionManager { return; } + // Script callers may observe only the running conversation owned by the same script. + // Missing owners are legacy/UI records and therefore fail closed for scripts. + if (params.scriptUuid !== undefined && rc.ownerScriptUuid !== params.scriptUuid) { + sendEvent({ type: "sync", tasks: [], status: "done" }); + return; + } + // 调用方持有的 generation 与实际运行中的会话不一致:会话已被删除重建, // 不能让旧一代的调用方附加到无关的新一代会话上 if (params.generation !== undefined && rc.generation !== params.generation) { diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index 97ff823b6..7a2f80468 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createTestService, makeSkillRecord, makeSkillScriptRecord, makeTextResponse } from "./test-helpers"; +import { + createMockSender, + createTestService, + makeSkillRecord, + makeSkillScriptRecord, + makeTextResponse, +} from "./test-helpers"; // ---- handleConversationChat skipSaveUserMessage(重新生成 bug 修复验证)---- @@ -206,6 +212,83 @@ describe("handleConversationChat skipSaveUserMessage", () => { }); }); +describe("CAT.agent.conversation owner isolation", () => { + it("creates a persisted conversation bound to the requesting script", async () => { + const { service, mockRepo } = createTestService(); + + await (service as any).handleConversationApi({ + action: "create", + options: { model: "test-openai" }, + scriptUuid: "script-a", + }); + + expect(mockRepo.createConversation).toHaveBeenCalledWith(expect.objectContaining({ ownerScriptUuid: "script-a" })); + }); + + it("does not expose an owned or legacy conversation to another script", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + { id: "legacy", title: "Legacy", modelId: "test-openai" }, + ]); + + await expect( + (service as any).handleConversationApi({ action: "get", id: "owned", scriptUuid: "script-b" }) + ).resolves.toBeNull(); + await expect( + (service as any).handleConversationApi({ action: "get", id: "legacy", scriptUuid: "script-a" }) + ).resolves.toBeNull(); + await expect( + (service as any).handleConversationApi({ action: "get", id: "owned", scriptUuid: "script-a" }) + ).resolves.toMatchObject({ id: "owned" }); + }); + + it("rejects every script mutation before it reaches message or conversation storage", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + ]); + const requests = [ + { action: "getMessages", conversationId: "owned" }, + { action: "save", conversationId: "owned" }, + { action: "clearMessages", conversationId: "owned" }, + { action: "deleteMessages", conversationId: "owned", messageIds: [] }, + { action: "delete", conversationId: "owned", generation: "gen" }, + ]; + + for (const request of requests) { + await expect((service as any).handleConversationApi({ ...request, scriptUuid: "script-b" })).rejects.toThrow( + "Conversation not found" + ); + } + + expect(mockRepo.getMessageSnapshot).not.toHaveBeenCalled(); + expect(mockRepo.saveMessages).not.toHaveBeenCalled(); + expect(mockRepo.deleteConversation).not.toHaveBeenCalled(); + }); + + it("rejects a foreign script's chat before loading history or calling the model", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + ]); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await (service as any).handleConversationChat( + { conversationId: "owned", message: "secret", scriptUuid: "script-b" }, + sender + ); + + expect(sentMessages.map((message) => message.data)).toContainEqual( + expect.objectContaining({ type: "error", message: "Conversation not found" }) + ); + expect(mockRepo.getMessages).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + describe("userscript 会话工具隔离", () => { it("携带 scriptUuid 时不注册无法交互的 ask_user 工具", async () => { const { service } = createTestService(); @@ -394,6 +477,7 @@ describe("handleConversationChat 场景补充", () => { id: "conv-1", title: "Test", modelId: "test-openai", + ownerScriptUuid: "script-1", generation: "gen-b", createtime: Date.now(), updatetime: Date.now(), diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 89f79bd28..738246641 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -269,8 +269,10 @@ export class ChatService { case "create": return this.createConversation(params); case "get": - return this.getConversation(params.id); + return this.getConversation(params.id, params.scriptUuid); case "getMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); // params.generation 提供时,与当前存储不一致(会话已被删除重建)则拒绝而非返回无关一代的消息; // 未提供 generation 时保留旧行为:会话不存在则返回空数组 try { @@ -281,15 +283,19 @@ export class ChatService { } case "save": { // 对话已经在 chat 过程中持久化,这里确保元数据也保存;仍需校验调用方持有的 generation - if (params.generation !== undefined) { - const conv = await this.getConversation(params.conversationId); - if (!conv || conv.generation !== params.generation) { - throw new Error("Conversation generation mismatch"); - } + const conv = + params.scriptUuid !== undefined || params.generation !== undefined + ? await this.getConversation(params.conversationId, params.scriptUuid) + : undefined; + if (params.scriptUuid !== undefined && !conv) throw new Error("Conversation not found"); + if (params.generation !== undefined && (!conv || conv.generation !== params.generation)) { + throw new Error("Conversation generation mismatch"); } return true; } case "clearMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); // 会话正在等待脚本工具结果时,这个 clear 很可能来自该工具 handler 内部的 // await conv.clear():chat 持有会话队列锁等待 toolResults,clear 排队等锁, // 相互等待成死锁。对这个窗口显式拒绝(fail fast);其余时刻仍与 chat/compact @@ -317,6 +323,8 @@ export class ChatService { return true; }); case "deleteMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); return stackAsyncTask(conversationChatLockKey(params.conversationId), async () => { const snapshot = await this.chatRepo.getMessageSnapshot(params.conversationId, params.generation); const ids = new Set(params.messageIds); @@ -333,6 +341,8 @@ export class ChatService { return true; }); case "delete": { + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); this.abortAdmittedChats(params.conversationId); this.bgSessionManager.stop(params.conversationId); return stackAsyncTask(conversationChatLockKey(params.conversationId), async () => { @@ -352,6 +362,7 @@ export class ChatService { const model = await this.modelService.getModel(params.options.model); const conv: Conversation = { id: params.options.id || uuidv4(), + ownerScriptUuid: params.scriptUuid, title: "New Chat", modelId: model.id, system: params.options.system, @@ -362,10 +373,10 @@ export class ChatService { return this.chatRepo.createConversation(conv); } - private async getConversation(id: string): Promise { + private async getConversation(id: string, scriptUuid?: string): Promise { const conversations = await this.chatRepo.listConversations(); const conversation = conversations.find((item) => item.id === id); - if (!conversation) return null; + if (!conversation || (scriptUuid !== undefined && conversation.ownerScriptUuid !== scriptUuid)) return null; return { ...conversation, generation: conversation.generation || `legacy:${conversation.id}`, @@ -373,6 +384,12 @@ export class ChatService { }; } + private async requireConversationAccess(id: string, scriptUuid: string): Promise { + const conversation = await this.getConversation(id, scriptUuid); + if (!conversation) throw new Error("Conversation not found"); + return conversation; + } + // 统一的流式 conversation chat(UI 和脚本 API 共用) // 同一 conversationId 的 chat / compact(compact 复用本方法的 params.compact 分支)都必须与 // clearMessages 串行执行,避免并发读改写互相覆盖对方的持久化写入。 @@ -402,6 +419,21 @@ export class ChatService { // 后台模式:非 ephemeral、非 compact 时可用 const isBackground = params.background === true && !params.ephemeral && !params.compact; + // Script callers must prove ownership before entering the queue or touching a connection. + // Legacy/UI conversations have no owner and therefore fail closed for scripts. + if (!params.ephemeral && params.scriptUuid !== undefined) { + const conversation = await this.getConversation(params.conversationId, params.scriptUuid); + if (!conversation) { + try { + msgConn.sendMessage({ action: "event", data: { type: "error", message: "Conversation not found" } }); + } catch { + // 端口已断开,无需通知 + } + await releaseProvisionalUserAttachments(); + return; + } + } + if (!params.ephemeral && this.conversationsAwaitingScriptTools.has(params.conversationId)) { try { msgConn.sendMessage({ @@ -619,7 +651,7 @@ export class ChatService { if (isBackground) { // 后台会话必须先确认调用方持有的 generation 与当前存储一致,否则一次删除重建后的 // 陈旧调用会静默附加到无关的新一代会话上 - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { await releaseProvisionalUserAttachments(); sendEventDirect({ type: "error", message: "Conversation not found" }); @@ -637,6 +669,7 @@ export class ChatService { rc = { conversationId: params.conversationId, generation: conv.generation!, + ownerScriptUuid: conv.ownerScriptUuid, abortController, listeners: new Set(), streamingState: { content: "", thinking: "", toolCalls: [] }, @@ -741,7 +774,7 @@ export class ChatService { } // 获取对话和模型 - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { sendEvent({ type: "error", message: "Conversation not found" }); return; @@ -924,7 +957,7 @@ export class ChatService { abortController: AbortController ): Promise { const startTime = Date.now(); - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { sendEvent({ type: "error", message: "Conversation not found" }); return; diff --git a/src/app/service/service_worker/gm_api/gm_agent.ts b/src/app/service/service_worker/gm_api/gm_agent.ts index d6b5bc59b..d6dcfc89f 100644 --- a/src/app/service/service_worker/gm_api/gm_agent.ts +++ b/src/app/service/service_worker/gm_api/gm_agent.ts @@ -38,7 +38,7 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleConversationApi(request.params[0]); + return this.agentService.handleConversationApi({ ...request.params[0], scriptUuid: request.script.uuid }); } @PermissionVerify.API({ @@ -50,7 +50,10 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleConversationChatFromGmApi(request.params[0], sender); + return this.agentService.handleConversationChatFromGmApi( + { ...request.params[0], scriptUuid: request.script.uuid }, + sender + ); } @PermissionVerify.API({ @@ -62,7 +65,10 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleAttachToConversationFromGmApi(request.params[0], sender); + return this.agentService.handleAttachToConversationFromGmApi( + { ...request.params[0], scriptUuid: request.script.uuid }, + sender + ); } } diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 1fd0b82db..1c40f3404 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -9,6 +9,7 @@ import GMApi, { } from "./gm_api"; import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; import type { GMApiRequest } from "../types"; +import GMAgentApi from "./gm_agent"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) import "./gm_api"; @@ -124,6 +125,54 @@ describe.concurrent("GM API 注册完整性", () => { }); }); +describe("CAT.agent.conversation identity binding", () => { + it("overrides a forged payload owner with the authenticated script", async () => { + const handleConversationApi = vi.fn().mockResolvedValue(null); + const api = { agentService: { handleConversationApi } } as unknown as GMApi; + const request = { + params: [{ action: "get", id: "conv-1", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + + await GMAgentApi.prototype.CAT_agentConversation.call(api, request, makeSender()); + + expect(handleConversationApi).toHaveBeenCalledWith({ + action: "get", + id: "conv-1", + scriptUuid: "script-authenticated", + }); + }); + + it("binds streaming chat and background attach to the authenticated script", async () => { + const handleConversationChatFromGmApi = vi.fn().mockResolvedValue(undefined); + const handleAttachToConversationFromGmApi = vi.fn().mockResolvedValue(undefined); + const api = { + agentService: { handleConversationChatFromGmApi, handleAttachToConversationFromGmApi }, + } as unknown as GMApi; + const chatRequest = { + params: [{ conversationId: "conv-1", message: "hi", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + const attachRequest = { + params: [{ conversationId: "conv-1", generation: "gen-1", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + const sender = makeSender(); + + await GMAgentApi.prototype.CAT_agentConversationChat.call(api, chatRequest, sender); + await GMAgentApi.prototype.CAT_agentAttachToConversation.call(api, attachRequest, sender); + + expect(handleConversationChatFromGmApi).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", scriptUuid: "script-authenticated" }), + sender + ); + expect(handleAttachToConversationFromGmApi).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", scriptUuid: "script-authenticated" }), + sender + ); + }); +}); + describe("page execution binding gate", () => { it("rejects a page-originated request that has no binding handle", async () => { const api = Object.create(GMApi.prototype) as GMApi; From 283edd2394d0afa7bf056dbcc491c141f15dc3c5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:35:57 +0900 Subject: [PATCH 065/106] =?UTF-8?q?=F0=9F=94=92=20bind=20USER=5FSCRIPT=20t?= =?UTF-8?q?okens=20to=20page=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 53 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 10 +++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index c751b49dc..04c8cc555 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1348,6 +1348,59 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); describe("USER_SCRIPT native callbacks", () => { + it("rejects bootstrap and reconnect tokens from a different URL when documentId is missing", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "url-bound-user-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const originalSender = { + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const connection = { + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const bootstrapSender = { + getType: () => 3, + isType: (type: number) => type === 3, + getSender: () => originalSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0 }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(originalSender)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptInjectBootstrapToken : undefined; + expect(bootstrapToken).toEqual(expect.any(String)); + + const navigatedSender = { + ...bootstrapSender, + getSender: () => ({ ...originalSender, url: "https://www.example.com/next" }), + }; + expect(runtime.registerUserScriptConnection({ world: "MAIN", bootstrapToken }, navigatedSender)).toBe(false); + expect( + runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + ...navigatedSender, + getType: () => 4, + isType: (type: number) => type === 4, + getConnect: () => undefined, + } + ) + ).toBeUndefined(); + }); + it("issues a separate MAIN bootstrap and routes its private callbacks over the native port", async () => { const { runtime } = _createRuntimeContext(); const script = _createScriptRunResource( diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 6e48ab886..4d369b33f 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -137,6 +137,7 @@ type UserScriptSession = { extensionOrigin?: ExtensionOrigin; reconnectToken: string; envTag: "it" | "ct"; + url: string; tabId: number; frameId?: number; documentId?: string; @@ -256,7 +257,7 @@ export class RuntimeService { /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { - // bootstrap token 只允许对应 tab/frame/document 使用一次,并且必须覆盖本次下发的全部句柄。 + // bootstrap token 只允许对应 tab/frame/document 使用一次;documentId 缺失时以 URL 作为文档身份,并且必须覆盖本次下发的全部句柄。 if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; if (data === null || typeof data !== "object") return false; const handshake = data as { world?: unknown; bootstrapToken?: unknown }; @@ -277,7 +278,9 @@ export class RuntimeService { !bootstrap || bootstrap.tabId !== tabId || bootstrap.frameId !== source.frameId || - bootstrap.documentId !== source.documentId + bootstrap.documentId !== source.documentId || + (bootstrap.documentId === undefined && + (typeof source.url !== "string" || source.url.length === 0 || bootstrap.url !== source.url)) ) { return false; } @@ -367,6 +370,8 @@ export class RuntimeService { candidateSession.tabId === tabId && candidateSession.frameId === source.frameId && candidateSession.documentId === source.documentId && + (candidateSession.documentId !== undefined || + (typeof source.url === "string" && source.url.length > 0 && candidateSession.url === source.url)) && candidateSession.reconnectToken === (data as { reconnectToken: string }).reconnectToken ) { key = candidateKey; @@ -1712,6 +1717,7 @@ export class RuntimeService { extensionOrigin: getExtensionOrigin(), reconnectToken: token, envTag, + url, tabId, frameId, documentId: chromeSender.documentId, From c8516c94fd9ebabf9f09d64c50f3397f9c52acc3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:50:57 +0900 Subject: [PATCH 066/106] =?UTF-8?q?=F0=9F=94=92=20bind=20early-start=20met?= =?UTF-8?q?adata=20to=20injected=20wrappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 95 +++++++++++++++++-- src/app/service/content/script_executor.ts | 88 ++++++++--------- src/app/service/content/utils.test.ts | 9 +- src/app/service/content/utils.ts | 17 ++-- 4 files changed, 150 insertions(+), 59 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index d2da26d4f..96955d226 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -4,6 +4,9 @@ import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; import type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; +import { compilePreInjectScript, preInjectScriptInfoKey } from "./utils"; +import { DefinedFlags } from "../service_worker/runtime.consts"; +import { pageDispatchEvent } from "@Packages/message/common"; const styleUrl = "https://example.com/style.css"; const secondStyleUrl = "https://example.com/second-style.css"; @@ -166,11 +169,12 @@ describe("ScriptExecutor", () => { try { pageWindow[script.flag] = attacker; - executor.execEarlyScript(script.flag, script, initEnvInfo); + executor.execEarlyScript(script.flag, initEnvInfo); expect(attacker).not.toHaveBeenCalled(); pageWindow[script.flag] = genuine; - executor.execEarlyScript(script.flag, script, initEnvInfo); + Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + executor.execEarlyScript(script.flag, initEnvInfo); expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); } finally { delete pageWindow[script.flag]; @@ -180,15 +184,90 @@ describe("ScriptExecutor", () => { it("rejects early metadata that retargets the flag or carries a page binding", () => { const script = makeScript({ flag: "#-executor-test-uuid" }); const executor = new ScriptExecutor({} as Message, {} as Message); - const genuine = vi.fn(); + const wrongUuid = vi.fn(); + const bound = vi.fn(); const pageWindow = window as unknown as Record; - Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(wrongUuid, fnStrIntegrity, { value: true }); + Object.defineProperty(wrongUuid, preInjectScriptInfoKey, { + value: JSON.stringify({ ...script, uuid: "other-script" }), + }); + Object.defineProperty(bound, fnStrIntegrity, { value: true }); + Object.defineProperty(bound, preInjectScriptInfoKey, { + value: JSON.stringify({ ...script, executionHandle: "other-binding" }), + }); try { - pageWindow[script.flag] = genuine; - executor.execEarlyScript(script.flag, { ...script, uuid: "other-script" }, initEnvInfo); - executor.execEarlyScript(script.flag, { ...script, executionHandle: "other-binding" }, initEnvInfo); - expect(genuine).not.toHaveBeenCalled(); + pageWindow[script.flag] = wrongUuid; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(wrongUuid).not.toHaveBeenCalled(); + + pageWindow[script.flag] = bound; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(bound).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects same-UUID early metadata mutations", () => { + const script = makeScript({ + uuid: "executor-early-authenticated-uuid", + flag: "#-executor-early-authenticated-uuid", + metadata: { grant: ["GM_getValue", "GM_getResourceText"], resource: ["canonical https://example.com/canonical"] }, + resource: { + canonical: { + url: "https://example.com/canonical", + content: "canonical", + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "resource", + link: {}, + contentType: "text/plain", + createtime: Date.now(), + }, + }, + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const performance = { dispatchEvent: vi.fn(() => false), addEventListener: vi.fn() }; + const generated = new Function("window", "performance", "CustomEvent", compilePreInjectScript(script, "")); + + try { + generated(pageWindow, performance, CustomEvent); + const forged = { + ...script, + metadata: { grant: ["GM_setValue"] }, + resource: { forged: { content: "forged", contentType: "text/plain" } }, + } as TScriptInfo; + + executor.checkEarlyStartScript("it", initEnvInfo); + const hostileDetail = {}; + const flagGetter = vi.fn(() => script.flag); + Object.defineProperty(hostileDetail, "scriptFlag", { get: flagGetter }); + pageDispatchEvent( + new CustomEvent(`evt${process.env.SC_RANDOM_KEY}.it${DefinedFlags.scriptLoadComplete}`, { + detail: hostileDetail, + cancelable: true, + }) + ); + expect(flagGetter).not.toHaveBeenCalled(); + + pageDispatchEvent( + new CustomEvent(`evt${process.env.SC_RANDOM_KEY}.it${DefinedFlags.scriptLoadComplete}`, { + detail: { scriptFlag: script.flag, scriptInfo: forged }, + cancelable: true, + }) + ); + + const exec = ( + executor as unknown as { + execScripts: Map; + } + ).execScripts.get(script.uuid); + expect(exec?.scriptRes.metadata).toEqual(script.metadata); + expect(exec?.scriptRes.resource).toEqual({ + canonical: { base64: "", content: "canonical", contentType: "text/plain" }, + }); } finally { delete pageWindow[script.flag]; } diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 68b9b980f..28b7781e8 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -3,13 +3,13 @@ import { getStorageName } from "@App/pkg/utils/utils"; import type { EmitEventRequest } from "../service_worker/types"; import ExecScript from "./exec_script"; import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; -import { addStyleSheet, definePropertyListener, waitBody } from "./utils"; -import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import { addStyleSheet, definePropertyListener, preInjectScriptInfoKey, waitBody } from "./utils"; +import type { TScriptInfo } from "@App/app/repo/scripts"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common"; import { isUrlExcluded } from "@App/pkg/utils/match"; import type { ScriptEnvTag } from "@Packages/message/consts"; -import { localizeObject, Native } from "./global"; +import { customClone, localizeObject, Native } from "./global"; // 与编译器相同的构建级标记,用来拒绝页面伪造的脚本挂载函数。 const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; @@ -110,39 +110,18 @@ export class ScriptExecutor { // 监听 脚本加载 // 适用于此「通知环境加载完成」代码执行后的脚本加载 const scriptLoadCompleteHandler: EventListener = (ev: Event) => { - const detail = (ev as CustomEvent).detail as { - scriptFlag: string; - scriptInfo: ScriptLoadInfo; - }; - const scriptFlag = detail?.scriptFlag; - const scriptInfo = detail?.scriptInfo; - if ( - typeof scriptFlag === "string" && - scriptInfo && - typeof scriptInfo === "object" && - scriptInfo.flag === scriptFlag - ) { - ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 - // 检查是否有 urlPattern,有则执行匹配再决定是否略过注入 - if (scriptInfo.scriptUrlPatterns) { - // 以 REGEX 情况为例 - // "@include /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围扩大,会比实际需要的广阔,然后在 earlyScript 把不符合 REGEX 的除去 - // (All @include = false -> 除去) - // 注:如果 @include 混合了 regex 跟 一般的,即使 regex 的 @include 不匹对当前网址,但匹对了一般 @include 也视为有效 - // 相反如果 @include 混合了 regex 跟 一般的,regex 的 @include 匹对了即可 - // "@exclude /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围不会扩大,然后在 earlyScript 把符合 REGEX 的匹配除去 - // (Any @exclude = true -> 除去) - // 注:如果一早已被除排,根本不会被 MV3 UserScripts API 注入。所以只考虑排除「多余的匹配」。(略过注入) - try { - if (isUrlExcluded(window.location.href, scriptInfo.scriptUrlPatterns)) { - // 「多余的匹配」-> 略过注入 - return; - } - } catch (e) { - console.warn("Unexpected match error", e); - } - } - if (!this.earlyScriptFlags.has(scriptFlag)) this.execEarlyScript(scriptFlag, scriptInfo, envInfo); + let scriptFlag: unknown; + try { + const detail = (ev as CustomEvent).detail; + if (!detail || typeof detail !== "object") return; + const flagDescriptor = Native.objectGetOwnPropertyDescriptor(detail, "scriptFlag"); + if (!flagDescriptor || !("value" in flagDescriptor)) return; + scriptFlag = flagDescriptor.value; + } catch { + return; + } + if (typeof scriptFlag === "string" && !this.earlyScriptFlags.has(scriptFlag)) { + if (this.execEarlyScript(scriptFlag, envInfo)) ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 } }; pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); @@ -152,21 +131,43 @@ export class ScriptExecutor { pageDispatchEvent(ev); } - execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { + execEarlyScript(flag: string, envInfo: GMInfoEnv) { + const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; + const descriptor = + typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) return; + // 事件在页面可见,只用预注入函数上的不可改写清单作为脚本资料来源。 + const scriptInfoDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) + : undefined; + if (!scriptInfoDescriptor || scriptInfoDescriptor.configurable || scriptInfoDescriptor.writable) return; + const scriptInfoJSON = scriptInfoDescriptor.value; + if (typeof scriptInfoJSON !== "string") return; + let scriptInfo: TScriptInfo | undefined; + try { + scriptInfo = customClone(Native.jsonParse(scriptInfoJSON)) as TScriptInfo | undefined; + } catch { + return; + } + if (!scriptInfo || scriptInfo.flag !== flag) return; const expectedUuid = flag.startsWith("#-") ? flag.slice(2) : undefined; - // early-start 事件来自页面,需同时确认脚本身份和未绑定状态,避免旧事件重放到新文档。 + if (expectedUuid && scriptInfo.uuid !== expectedUuid) return; if ( - (expectedUuid && scriptInfo.uuid !== expectedUuid) || scriptInfo.executionHandle !== undefined || scriptInfo.executionEnvTag !== undefined || scriptInfo.executionRunFlag !== undefined ) { return; } - const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; - const descriptor = - typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; - if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) return; + // MV3 对正则匹配会放宽注入范围,必须用编译器绑定的模式在当前页面再确认一次。 + if (scriptInfo.scriptUrlPatterns) { + try { + if (isUrlExcluded(window.location.href, scriptInfo.scriptUrlPatterns)) return; + } catch (e) { + console.warn("Unexpected match error", e); + } + } this.execScriptEntry({ scriptLoadInfo: scriptInfo, scriptFunc: scriptFunc, @@ -174,6 +175,7 @@ export class ScriptExecutor { envInfo: envInfo, }); this.earlyScriptFlags.add(flag); + return true; } execScriptEntry(scriptEntry: ExecScriptEntry) { diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index e73da126c..cf1dd2d53 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -8,6 +8,7 @@ import { isScriptletUnwrap, addStyle, addStyleSheet, + preInjectScriptInfoKey, trimScriptInfo, } from "./utils"; import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; @@ -782,6 +783,11 @@ describe("utils", () => { ); const generated = targetWindow[script.flag] as ScriptFunc; + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptInfoKey)).toMatchObject({ + configurable: false, + writable: false, + value: expect.any(String), + }); const context = {}; const named = { value: 42 }; expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ @@ -825,8 +831,7 @@ describe("utils", () => { executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), {}, testPerformance); - expect(detail?.scriptInfo.value).toEqual({}); - expect(detail?.scriptInfo.config).toBeUndefined(); + expect(detail).toEqual({ scriptFlag: script.flag }); }); it.concurrent("does not mount a regex-excluded early-start script", () => { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 23b165f1f..47450ac82 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -17,6 +17,7 @@ const cloneTransportValue = (value: any) => { // 与 rspack 注入的构建级密钥配对;页面只能看到包装函数,拿不到正确的调用标记。 const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; +export const preInjectScriptInfoKey = `${lnStrIntegrity}:scriptInfo`; export type CompileScriptCodeResource = { name: string; @@ -171,14 +172,18 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } -const codeFunction = (code: string) => { +const codeFunction = (code: string, scriptInfoJSON?: string) => { // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 - return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; + const infoProperty = + scriptInfoJSON === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} });`; + return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; // 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 -const mountCodeFunction = (flag: string, code: string) => - `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code)})`; +const mountCodeFunction = (flag: string, code: string, scriptInfoJSON?: string) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON)})`; const ZFunction = Function; @@ -316,10 +321,10 @@ export function compilePreInjectScript( f = () => { if (!(${urlCondition})) return false; if (!mounted) { - ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)}; + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON)}; mounted = true; } - const o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, + const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o; return performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)); }, From 20d39afaf97550af51c128142b5f7239a6593cff Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:11:40 +0900 Subject: [PATCH 067/106] =?UTF-8?q?=F0=9F=94=92=20restore=20early-start=20?= =?UTF-8?q?manifest=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/script_executor.test.ts | 17 +++++++++++++++++ src/app/service/content/script_executor.ts | 13 +++++++++---- src/app/service/content/utils.ts | 2 +- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 96955d226..fc74a2277 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -273,6 +273,23 @@ describe("ScriptExecutor", () => { } }); + it("accepts the immutable early manifest through the wrapper name fallback", () => { + const script = makeScript({ uuid: "executor-early-name-uuid", flag: "#-executor-early-name-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, "name", { configurable: false, value: JSON.stringify(script) }); + + try { + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + it("continues loading later scripts after reconciling an early-start entry", () => { const early = makeScript({ uuid: "early-script", diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 28b7781e8..3fc61df2c 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -9,7 +9,7 @@ import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common"; import { isUrlExcluded } from "@App/pkg/utils/match"; import type { ScriptEnvTag } from "@Packages/message/consts"; -import { customClone, localizeObject, Native } from "./global"; +import { localizeObject, Native } from "./global"; // 与编译器相同的构建级标记,用来拒绝页面伪造的脚本挂载函数。 const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; @@ -141,12 +141,17 @@ export class ScriptExecutor { typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) : undefined; - if (!scriptInfoDescriptor || scriptInfoDescriptor.configurable || scriptInfoDescriptor.writable) return; - const scriptInfoJSON = scriptInfoDescriptor.value; + if (scriptInfoDescriptor?.configurable || scriptInfoDescriptor?.writable) return; + const scriptInfoJSON = + typeof scriptInfoDescriptor?.value === "string" + ? scriptInfoDescriptor.value + : typeof scriptFunc.name === "string" + ? scriptFunc.name + : undefined; if (typeof scriptInfoJSON !== "string") return; let scriptInfo: TScriptInfo | undefined; try { - scriptInfo = customClone(Native.jsonParse(scriptInfoJSON)) as TScriptInfo | undefined; + scriptInfo = Native.jsonParse(scriptInfoJSON) as TScriptInfo | undefined; } catch { return; } diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 47450ac82..622911dcc 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -177,7 +177,7 @@ const codeFunction = (code: string, scriptInfoJSON?: string) => { const infoProperty = scriptInfoJSON === undefined ? "" - : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} });`; + : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });`; return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; From 3372fbe72929c2e37a524201fed54b388d28d9b7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:17:44 +0900 Subject: [PATCH 068/106] =?UTF-8?q?=F0=9F=94=92=20retire=20stale=20page=20?= =?UTF-8?q?bindings=20on=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 33 ++++++++++++++++++- src/app/service/service_worker/runtime.ts | 15 ++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 04c8cc555..1e696b3eb 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1223,7 +1223,7 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(secondRunFlag).toEqual(expect.any(String)); expect(secondHandle).not.toBe(firstHandle); expect(secondRunFlag).not.toBe(firstRunFlag); - expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeDefined(); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeDefined(); runtime.revokePageBindingsForTab(41); @@ -1231,6 +1231,37 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); }); + it("新文档加载时撤销上一文档的执行绑定", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource(_createMockScript({ uuid: "navigation-bound-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const firstRawSender = { + url: "https://www.example.com/first", + frameId: 0, + documentId: "doc-first", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const firstSender = new SenderRuntime(firstRawSender); + const firstLoad = await runtime.pageLoad(undefined, firstSender); + expect(firstLoad.ok).toBe(true); + if (!firstLoad.ok) return; + const firstHandle = firstLoad.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(firstHandle!, firstSender)).toBeDefined(); + + const secondRawSender = { ...firstRawSender, url: "https://www.example.com/second", documentId: "doc-second" }; + const secondSender = new SenderRuntime(secondRawSender); + const secondLoad = await runtime.pageLoad(undefined, secondSender); + expect(secondLoad.ok).toBe(true); + if (!secondLoad.ok) return; + + expect(runtime.resolvePageExecutionBinding(firstHandle!, firstSender)).toBeUndefined(); + }); + it("rejects a stale URL when the browser omits documentId", async () => { const { runtime } = _createRuntimeContext(); const script = _createScriptRunResource( diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 4d369b33f..6d654e04c 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -179,33 +179,30 @@ export class RuntimeService { } private revokePageBindings(sender: IGetSender, envTag?: "it" | "ct"): void { - // documentId 缺失时仍按 tab/frame 退休旧绑定,避免新页面继承上一文档的授权。 + // pageLoad 是文档切换信号;按 tab/frame 退休旧句柄,避免旧文档继续使用上一页的权限。 const source = sender.getSender(); const tabId = source?.tab?.id; const frameId = source?.frameId; - const documentId = source?.documentId; for (const [handle, binding] of this.pageExecutionBindings) { if ( binding.tabId === tabId && binding.frameId === frameId && - (envTag === undefined || binding.envTag === envTag || (envTag === "it" && binding.envTag === "ct")) && - (documentId === undefined || binding.documentId === documentId) + (envTag === undefined || binding.envTag === envTag || (envTag === "it" && binding.envTag === "ct")) ) { this.pageExecutionBindings.delete(handle); } } if (envTag === "it") { for (const [key, entry] of this.userScriptConnections) { - if ( - entry.tabId === tabId && - entry.frameId === frameId && - (documentId === undefined || entry.documentId === documentId) - ) { + if (entry.tabId === tabId && entry.frameId === frameId) { entry.connection.disconnect(true); this.userScriptConnections.delete(key); this.userScriptSessions.delete(key); } } + for (const [key, session] of this.userScriptSessions) { + if (session.tabId === tabId && session.frameId === frameId) this.userScriptSessions.delete(key); + } } if (envTag !== "ct") { for (const [token, bootstrap] of this.userScriptBootstraps) { From f371261aa74f3cf0461114be1ce62688fd1f6340 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:22:54 +0900 Subject: [PATCH 069/106] =?UTF-8?q?=F0=9F=94=92=20bind=20CAT=20agent=20ser?= =?UTF-8?q?vice=20identities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/gm_api/gm_agent_dom.ts | 2 +- .../service_worker/gm_api/gm_agent_model.ts | 2 +- .../service_worker/gm_api/gm_agent_opfs.ts | 2 +- .../service_worker/gm_api/gm_agent_skills.ts | 2 +- .../service_worker/gm_api/gm_api.test.ts | 44 +++++++++++++++++++ 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_agent_dom.ts b/src/app/service/service_worker/gm_api/gm_agent_dom.ts index c9e087265..043f22247 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_dom.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_dom.ts @@ -36,7 +36,7 @@ class GMAgentDomApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleDomApi(request.params[0]); + return this.agentService.handleDomApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_model.ts b/src/app/service/service_worker/gm_api/gm_agent_model.ts index 87eb9474b..ce4cc36de 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_model.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_model.ts @@ -38,7 +38,7 @@ class GMAgentModelApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleModelApi(request.params[0]); + return this.agentService.handleModelApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts index 70d993688..9122bfeff 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts @@ -50,7 +50,7 @@ class GMAgentOPFSApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleOPFSApi(request.params[0], sender); + return this.agentService.handleOPFSApi({ ...request.params[0], scriptUuid: request.script.uuid }, sender); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_skills.ts b/src/app/service/service_worker/gm_api/gm_agent_skills.ts index 47b961793..90c8e33a0 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_skills.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_skills.ts @@ -50,7 +50,7 @@ class GMAgentSkillsApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleSkillsApi(request.params[0]); + return this.agentService.handleSkillsApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 1c40f3404..094a9f46d 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -10,6 +10,10 @@ import GMApi, { import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; import type { GMApiRequest } from "../types"; import GMAgentApi from "./gm_agent"; +import GMAgentDomApi from "./gm_agent_dom"; +import GMAgentModelApi from "./gm_agent_model"; +import GMAgentOPFSApi from "./gm_agent_opfs"; +import GMAgentSkillsApi from "./gm_agent_skills"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) import "./gm_api"; @@ -173,6 +177,46 @@ describe("CAT.agent.conversation identity binding", () => { }); }); +describe("CAT agent identity binding", () => { + it("overrides forged nested scriptUuid values for every agent service boundary", async () => { + const handleDomApi = vi.fn().mockResolvedValue(undefined); + const handleModelApi = vi.fn().mockResolvedValue(undefined); + const handleSkillsApi = vi.fn().mockResolvedValue(undefined); + const handleOPFSApi = vi.fn().mockResolvedValue(undefined); + const api = { + agentService: { handleDomApi, handleModelApi, handleSkillsApi, handleOPFSApi }, + } as unknown as GMApi; + const script = { uuid: "script-authenticated" }; + const sender = makeSender(); + + await GMAgentDomApi.prototype.CAT_agentDom.call( + api, + { params: [{ action: "listTabs", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentModelApi.prototype.CAT_agentModel.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentSkillsApi.prototype.CAT_agentSkills.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentOPFSApi.prototype.CAT_agentOPFS.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + + expect(handleDomApi).toHaveBeenCalledWith({ action: "listTabs", scriptUuid: "script-authenticated" }); + expect(handleModelApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }); + expect(handleSkillsApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }); + expect(handleOPFSApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }, sender); + }); +}); + describe("page execution binding gate", () => { it("rejects a page-originated request that has no binding handle", async () => { const api = Object.create(GMApi.prototype) as GMApi; From 3ab6b2dcb276541377d9df2f33f1b681dd49a290 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:26:41 +0900 Subject: [PATCH 070/106] =?UTF-8?q?=F0=9F=94=92=20target=20private=20callb?= =?UTF-8?q?acks=20to=20frame=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/extension_message.test.ts | 19 ++++++++++++++++++- packages/message/extension_message.ts | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts index efae4b4fc..9b510a051 100644 --- a/packages/message/extension_message.test.ts +++ b/packages/message/extension_message.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; +import { ExtensionContentMessageSend, ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; describe("ExtensionMessage USER_SCRIPT compatibility", () => { it("does not require unavailable runtime event listeners", () => { @@ -64,4 +64,21 @@ describe("ExtensionMessage USER_SCRIPT compatibility", () => { expect(nativePostMessage).toHaveBeenCalledWith({ action: "native" }); connection.disconnect(true); }); + + it("preserves an explicit main-frame target when frameId is zero", async () => { + const sendMessage = vi + .spyOn(chrome.tabs, "sendMessage") + .mockImplementation((_tabId, _message, optionsOrCallback, callback) => { + const responseCallback = typeof optionsOrCallback === "function" ? optionsOrCallback : callback; + responseCallback?.({ success: true }); + return Promise.resolve({ success: true }); + }); + try { + await new ExtensionContentMessageSend(7, { frameId: 0 }).sendMessage({ action: "private" }); + + expect(sendMessage).toHaveBeenCalledWith(7, { action: "private" }, { frameId: 0 }, expect.any(Function)); + } finally { + sendMessage.mockRestore(); + } + }); }); diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 2fcd610a6..06fac671b 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -285,7 +285,7 @@ export class ExtensionContentMessageSend implements MessageSend { sendMessage(data: TMessage): Promise { return new Promise((resolve) => { - if (!this.options?.documentId && !this.options?.frameId) { + if (this.options?.documentId === undefined && this.options?.frameId === undefined) { // 发送给指定的tab chrome.tabs.sendMessage(this.tabId, data, (resp: T) => { const lastError = chrome.runtime.lastError; From 2f5b1fb424940283269a6219c26652c62e2d83e9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:05:51 +0900 Subject: [PATCH 071/106] =?UTF-8?q?=F0=9F=94=92=20bind=20DOM=20monitors=20?= =?UTF-8?q?to=20script=20owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/agent/service_worker/dom.test.ts | 8 ++++++ src/app/service/agent/service_worker/dom.ts | 20 ++++++++------- .../agent/service_worker/dom_cdp.test.ts | 25 ++++++++++++++++++- .../service/agent/service_worker/dom_cdp.ts | 25 +++++++++++++------ 4 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/app/service/agent/service_worker/dom.test.ts b/src/app/service/agent/service_worker/dom.test.ts index a0cf6829d..9d9938a24 100644 --- a/src/app/service/agent/service_worker/dom.test.ts +++ b/src/app/service/agent/service_worker/dom.test.ts @@ -406,6 +406,14 @@ describe("AgentDomService", () => { }); }); + describe("monitor", () => { + it("应拒绝在浏览器内部页面启动监控", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "chrome://settings", status: "complete", discarded: false }); + + await expect(service.startMonitor(1)).rejects.toThrow("Agent DOM operation not allowed for URL:"); + }); + }); + describe("resolveTabId", () => { it("应在 tab 被 discard 时自动 reload", async () => { mockTabsGet.mockResolvedValueOnce({ diff --git a/src/app/service/agent/service_worker/dom.ts b/src/app/service/agent/service_worker/dom.ts index df0f6d951..7a1ac953e 100644 --- a/src/app/service/agent/service_worker/dom.ts +++ b/src/app/service/agent/service_worker/dom.ts @@ -264,18 +264,20 @@ export class AgentDomService { } // 启动页面监控(CDP:dialog 自动处理 + MutationObserver) - async startMonitor(tabId: number): Promise { - return cdpStartMonitor(tabId); + async startMonitor(tabId: number, scriptUuid?: string): Promise { + const tab = await chrome.tabs.get(tabId); + assertDomUrlAllowed(tab.url || ""); + return cdpStartMonitor(tabId, scriptUuid); } // 停止监控并返回收集的结果 - async stopMonitor(tabId: number): Promise { - return cdpStopMonitor(tabId); + async stopMonitor(tabId: number, scriptUuid?: string): Promise { + return cdpStopMonitor(tabId, scriptUuid); } // 查询当前 monitor 状态(不停止监控) - peekMonitor(tabId: number): MonitorStatus { - return cdpPeekMonitor(tabId); + peekMonitor(tabId: number, scriptUuid?: string): MonitorStatus { + return cdpPeekMonitor(tabId, scriptUuid); } // 处理 GM API 请求路由 @@ -300,11 +302,11 @@ export class AgentDomService { case "executeScript": return this.executeScript(request.code, request.options); case "startMonitor": - return this.startMonitor(request.tabId); + return this.startMonitor(request.tabId, request.scriptUuid); case "stopMonitor": - return this.stopMonitor(request.tabId); + return this.stopMonitor(request.tabId, request.scriptUuid); case "peekMonitor": - return this.peekMonitor(request.tabId); + return this.peekMonitor(request.tabId, request.scriptUuid); default: throw new Error(`Unknown DOM action: ${(request as any).action}`); } diff --git a/src/app/service/agent/service_worker/dom_cdp.test.ts b/src/app/service/agent/service_worker/dom_cdp.test.ts index f09cfa183..3a84003b5 100644 --- a/src/app/service/agent/service_worker/dom_cdp.test.ts +++ b/src/app/service/agent/service_worker/dom_cdp.test.ts @@ -17,7 +17,7 @@ vi.stubGlobal("chrome", { tabs: { get: mockTabsGet }, }); -import { cdpClick } from "./dom_cdp"; +import { cdpClick, cdpPeekMonitor, cdpStartMonitor, cdpStopMonitor } from "./dom_cdp"; afterAll(() => { vi.stubGlobal("chrome", savedChrome); @@ -91,4 +91,27 @@ describe("agent_dom_cdp", () => { }); await expect(cdpClick(999, "#nonexistent")).rejects.toThrow(/Element not found/); }); + + it("页面监控只能由创建它的脚本重新启动", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await cdpStartMonitor(999, "script-a"); + + await expect(cdpStartMonitor(999, "script-b")).rejects.toThrow("Monitor belongs to another script"); + + await cdpStopMonitor(999, "script-a"); + }); + + it("页面监控的结果不能被其他脚本读取或停止", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await cdpStartMonitor(998, "script-a"); + + expect(cdpPeekMonitor(998, "script-b")).toEqual({ hasChanges: false, dialogCount: 0, nodeCount: 0 }); + await expect(cdpStopMonitor(998, "script-b")).rejects.toThrow("Monitor belongs to another script"); + + await cdpStopMonitor(998, "script-a"); + }); }); diff --git a/src/app/service/agent/service_worker/dom_cdp.ts b/src/app/service/agent/service_worker/dom_cdp.ts index f05980868..0a0334f30 100644 --- a/src/app/service/agent/service_worker/dom_cdp.ts +++ b/src/app/service/agent/service_worker/dom_cdp.ts @@ -16,6 +16,7 @@ type CapturedNode = { }; type MonitorSession = { + ownerScriptUuid?: string; dialogs: Array<{ type: string; message: string }>; capturedNodes: CapturedNode[]; // 从事件中直接提取的节点信息 listener: MonitorEventListener; @@ -239,10 +240,14 @@ export async function cdpScreenshot(tabId: number, options?: ScreenshotOptions): // ---- 页面监控(startMonitor / stopMonitor) ---- // 启动页面监控:attach debugger,纯 CDP 事件监听(dialog + DOM 变化),零注入 -export async function cdpStartMonitor(tabId: number): Promise { +export async function cdpStartMonitor(tabId: number, ownerScriptUuid?: string): Promise { // 如果已有 monitor,先停止 - if (activeMonitors.has(tabId)) { - await cdpStopMonitor(tabId); + const current = activeMonitors.get(tabId); + if (current) { + if (current.ownerScriptUuid !== ownerScriptUuid) { + throw new Error("Monitor belongs to another script"); + } + await cdpStopMonitor(tabId, ownerScriptUuid); } const dialogs: Array<{ type: string; message: string }> = []; @@ -292,13 +297,16 @@ export async function cdpStartMonitor(tabId: number): Promise { }; chrome.debugger.onEvent.addListener(listener); - activeMonitors.set(tabId, { dialogs, capturedNodes, listener }); + activeMonitors.set(tabId, { ownerScriptUuid, dialogs, capturedNodes, listener }); } // 轻量查询当前 monitor 状态(不停止监控) -export function cdpPeekMonitor(tabId: number): { hasChanges: boolean; dialogCount: number; nodeCount: number } { +export function cdpPeekMonitor( + tabId: number, + ownerScriptUuid?: string +): { hasChanges: boolean; dialogCount: number; nodeCount: number } { const monitor = activeMonitors.get(tabId); - if (!monitor) { + if (!monitor || monitor.ownerScriptUuid !== ownerScriptUuid) { return { hasChanges: false, dialogCount: 0, nodeCount: 0 }; } const dialogCount = monitor.dialogs.length; @@ -315,8 +323,11 @@ function stripHtmlTags(html: string): string { } // 停止监控:纯 CDP 解析新增节点 → 收集结果 → detach -export async function cdpStopMonitor(tabId: number): Promise { +export async function cdpStopMonitor(tabId: number, ownerScriptUuid?: string): Promise { const monitor = activeMonitors.get(tabId); + if (monitor && monitor.ownerScriptUuid !== ownerScriptUuid) { + throw new Error("Monitor belongs to another script"); + } const result: MonitorResult = { dialogs: monitor?.dialogs || [], addedNodes: [], From 1f769a6c26764375b5bda24bf283fcb70eb364b0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:14:18 +0900 Subject: [PATCH 072/106] =?UTF-8?q?=F0=9F=94=92=20gate=20MAIN=20page=20boo?= =?UTF-8?q?tstrap=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/extension_message.ts | 1 + .../content/main_world_page_load_gate.test.ts | 60 +++++++++++++++++++ .../content/main_world_page_load_gate.ts | 42 +++++++++++++ src/inject.ts | 21 ++++--- 4 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 src/app/service/content/main_world_page_load_gate.test.ts create mode 100644 src/app/service/content/main_world_page_load_gate.ts diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 06fac671b..6d39dcf50 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -18,6 +18,7 @@ const nativeRuntimeConnect = typeof runtimeApi?.connect === "function" ? runtimeApi.connect.bind(runtimeApi) : undefined; const nativeRuntimeSendMessage = typeof runtimeApi?.sendMessage === "function" ? runtimeApi.sendMessage.bind(runtimeApi) : undefined; +export const hasNativeRuntimeChannel = nativeRuntimeConnect !== undefined && nativeRuntimeSendMessage !== undefined; export class ExtensionMessage implements Message { constructor(private backgroundPrimary = false) {} diff --git a/src/app/service/content/main_world_page_load_gate.test.ts b/src/app/service/content/main_world_page_load_gate.test.ts new file mode 100644 index 000000000..9a273a4ed --- /dev/null +++ b/src/app/service/content/main_world_page_load_gate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMainWorldPageLoadGate } from "./main_world_page_load_gate"; + +describe("createMainWorldPageLoadGate", () => { + it("does not deliver the page-visible payload while the native channel is opening", async () => { + let resolveNative!: (connected: boolean) => void; + const openNativeChannel = vi.fn( + () => + new Promise((resolve) => { + resolveNative = resolve; + }) + ); + const receivePageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(openNativeChannel, receivePageLoad); + + gate.onPageLoad({ source: "page" }); + gate.onBootstrap("bootstrap-token"); + + expect(openNativeChannel).toHaveBeenCalledWith("bootstrap-token"); + expect(receivePageLoad).not.toHaveBeenCalled(); + + resolveNative(true); + await Promise.resolve(); + expect(receivePageLoad).not.toHaveBeenCalled(); + + gate.onPageLoad({ source: "replay" }); + expect(receivePageLoad).not.toHaveBeenCalled(); + }); + + it("releases one queued payload only when native transport is unavailable", async () => { + const receivePageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => false, receivePageLoad); + const first = { source: "page" }; + const second = { source: "page-after-fallback" }; + + gate.onPageLoad(first); + gate.onBootstrap("bootstrap-token"); + await Promise.resolve(); + + expect(receivePageLoad).toHaveBeenCalledWith(first); + + gate.onPageLoad(second); + expect(receivePageLoad).toHaveBeenLastCalledWith(second); + expect(receivePageLoad).toHaveBeenCalledTimes(2); + }); + + it("does not reopen or fall back after the native channel has been selected", async () => { + const receivePageLoad = vi.fn(); + const openNativeChannel = vi.fn(async () => true); + const gate = createMainWorldPageLoadGate(openNativeChannel, receivePageLoad); + + gate.onBootstrap("first-token"); + await Promise.resolve(); + gate.onBootstrap("second-token"); + gate.onPageLoad({ source: "replay" }); + + expect(openNativeChannel).toHaveBeenCalledOnce(); + expect(receivePageLoad).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/content/main_world_page_load_gate.ts b/src/app/service/content/main_world_page_load_gate.ts new file mode 100644 index 000000000..9d937cc70 --- /dev/null +++ b/src/app/service/content/main_world_page_load_gate.ts @@ -0,0 +1,42 @@ +type MainWorldPageLoadGateState = "waiting" | "opening" | "native" | "fallback"; + +export type MainWorldPageLoadGate = { + onBootstrap: (bootstrapToken: string) => void; + onPageLoad: (data: unknown) => void; +}; + +export const createMainWorldPageLoadGate = ( + openNativeChannel: (bootstrapToken: string) => Promise, + receivePageLoad: (data: unknown) => void +): MainWorldPageLoadGate => { + let state: MainWorldPageLoadGateState = "waiting"; + let pendingPageLoad: unknown; + let hasPendingPageLoad = false; + + const finishOpening = (connected: boolean): void => { + if (state !== "opening") return; + state = connected ? "native" : "fallback"; + if (state === "fallback" && hasPendingPageLoad) { + receivePageLoad(pendingPageLoad); + } + pendingPageLoad = undefined; + hasPendingPageLoad = false; + }; + + return { + onBootstrap(bootstrapToken) { + if (state !== "waiting") return; + state = "opening"; + void openNativeChannel(bootstrapToken).then(finishOpening, () => finishOpening(false)); + }, + onPageLoad(data) { + if (state === "fallback") { + receivePageLoad(data); + return; + } + if (state === "native") return; + pendingPageLoad = data; + hasPendingPageLoad = true; + }, + }; +}; diff --git a/src/inject.ts b/src/inject.ts index b00e5257a..44619cc9c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -2,7 +2,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { PageMessage } from "@Packages/message/page_message"; -import { ExtensionMessage } from "@Packages/message/extension_message"; +import { ExtensionMessage, hasNativeRuntimeChannel } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; @@ -12,6 +12,7 @@ import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; import type { MessageConnect, TMessage } from "@Packages/message/types"; +import { createMainWorldPageLoadGate } from "./app/service/content/main_world_page_load_gate"; const messageFlag = process.env.SC_RANDOM_KEY!; @@ -21,10 +22,7 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const pageMsg: Message = new PageMessage(eventFlag, "inject"); const nativeMsg: Message = new ExtensionMessage(false); // 特权 GM RPC 使用浏览器标记的 USER_SCRIPT 来源;页面桥只保留 bootstrap 与 DOM 引用辅助。 - const canUseNativeChannel = - typeof chrome !== "undefined" && - typeof chrome.runtime?.connect === "function" && - typeof chrome.runtime?.sendMessage === "function"; + const canUseNativeChannel = hasNativeRuntimeChannel; const msg: Message = canUseNativeChannel ? nativeMsg : pageMsg; // 初始化日志组件 @@ -60,8 +58,8 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde } }; - const openNativeChannel = async (bootstrapToken: string): Promise => { - if (openingNative || nativeConnection) return; + const openNativeChannel = async (bootstrapToken: string): Promise => { + if (openingNative || nativeConnection) return Boolean(nativeConnection); openingNative = true; let connection: MessageConnect | undefined; try { @@ -83,22 +81,23 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde "MAIN" ); nativeConnection = connection; + return connection !== undefined; } catch (error) { logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); + return false; } finally { openingNative = false; } }; if (pageServer) { + const pageLoadGate = createMainWorldPageLoadGate(openNativeChannel, (data) => runtime.receivePageLoad(data)); pageServer.on("bootstrap", (data: { bootstrapToken?: unknown }) => { if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; reconnectToken = data.bootstrapToken; - void openNativeChannel(data.bootstrapToken); - }); - pageServer.on("pageLoad", (data) => { - runtime.receivePageLoad(data); + pageLoadGate.onBootstrap(data.bootstrapToken); }); + pageServer.on("pageLoad", pageLoadGate.onPageLoad); } runtime.init(); From 2f8dcd698dd01bafedf980ca6a07485fc06248cb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:31:44 +0900 Subject: [PATCH 073/106] =?UTF-8?q?=F0=9F=94=92=20preserve=20USER=5FSCRIPT?= =?UTF-8?q?=20native=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 4 +- packages/message/extension_message.test.ts | 39 +++++++++++++++++++ packages/message/extension_message.ts | 17 ++++++-- .../content/user_script_connection.test.ts | 25 ++++++++++-- .../service/content/user_script_connection.ts | 16 +++++--- .../service/service_worker/runtime.test.ts | 15 ++++++- src/app/service/service_worker/runtime.ts | 21 ++++++---- 7 files changed, 114 insertions(+), 23 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index bc812f73f..3b33262f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ Each context is a separate bundle (see [Build pipeline & manifest](./references/ | Context | Entry | Realm / capabilities | Bootstraps | |---|---|---|---| | **Service Worker** | [`src/service_worker.ts`](../src/service_worker.ts) | No DOM. Owns `chrome.*` privileged APIs, storage, permissions, routing. | `ExtensionMessage(true)` → `Server("serviceWorker")` + `MessageQueue` → `ServiceWorkerManager` | -| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles | +| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; dedicated USER_SCRIPT listeners are preferred, with a token-bound regular port fallback when they cannot be registered. Retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles | | **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `CustomEventMessage` to content + `Server("inject")` | | **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | `ExtensionMessage()` + `WindowMessage(window, sandbox)` → `OffscreenManager` | | **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen. Evaluates background/scheduled scripts; runs cron. | `WindowMessage(window, parent)` + `Server("sandbox")` → `SandboxManager` | @@ -167,7 +167,7 @@ communication styles** over **several transports**. | Class | File | Connects | Underlying API | |---|---|---|---| -| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox) | +| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox; token-bound regular-port fallback when dedicated listeners are unavailable) | | `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ Inject | DOM `CustomEvent` dispatch (bypasses page tampering) | | `WindowMessage` | [`window_message.ts`](../packages/message/window_message.ts) | Offscreen ↔ Sandbox | `window.postMessage` | | `ServiceWorkerMessageSend` | [`window_message.ts`](../packages/message/window_message.ts) | SW → Offscreen (Chrome) | `clients.matchAll()` + `postMessage` | diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts index 9b510a051..a547f7eb3 100644 --- a/packages/message/extension_message.test.ts +++ b/packages/message/extension_message.test.ts @@ -2,6 +2,45 @@ import { describe, expect, it, vi } from "vitest"; import { ExtensionContentMessageSend, ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; describe("ExtensionMessage USER_SCRIPT compatibility", () => { + it("reports a failed dedicated listener registration so USER_SCRIPT can use the regular-port fallback", () => { + const runtime = chrome.runtime as unknown as { + onUserScriptConnect?: { addListener: (callback: (...args: any[]) => void) => void }; + onUserScriptMessage?: { addListener: (callback: (...args: any[]) => void) => void }; + messageListener?: Array<(message: any, sender: any, sendResponse: (response: any) => void) => void>; + connectListener?: Array<(port: chrome.runtime.Port) => void>; + }; + const originalConnect = runtime.onUserScriptConnect; + const originalMessage = runtime.onUserScriptMessage; + const initialMessageListenerCount = runtime.messageListener?.length ?? 0; + const initialConnectListenerCount = runtime.connectListener?.length ?? 0; + try { + runtime.onUserScriptConnect = { + addListener: () => { + throw new Error("userScripts permission unavailable"); + }, + }; + runtime.onUserScriptMessage = { + addListener: () => { + throw new Error("userScripts permission unavailable"); + }, + }; + const message = new ExtensionMessage(true); + message.onConnect(() => undefined); + message.onMessage(() => undefined); + + const response = vi.fn(); + const listeners = runtime.messageListener ?? []; + listeners.at(-1)?.({ type: "userScripts.LISTEN_CONNECTIONS" }, {}, response); + + expect(response).toHaveBeenCalledWith(false); + } finally { + if (runtime.messageListener) runtime.messageListener.length = initialMessageListenerCount; + if (runtime.connectListener) runtime.connectListener.length = initialConnectListenerCount; + runtime.onUserScriptConnect = originalConnect; + runtime.onUserScriptMessage = originalMessage; + } + }); + it("does not require unavailable runtime event listeners", () => { const runtime = chrome.runtime as unknown as { onConnect?: typeof chrome.runtime.onConnect; diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index 6d39dcf50..4d4646705 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -21,6 +21,9 @@ const nativeRuntimeSendMessage = export const hasNativeRuntimeChannel = nativeRuntimeConnect !== undefined && nativeRuntimeSendMessage !== undefined; export class ExtensionMessage implements Message { + private userScriptConnectionListenerReady = false; + private userScriptMessageListenerReady = false; + constructor(private backgroundPrimary = false) {} connect(data: TMessage): Promise { @@ -100,14 +103,17 @@ export class ExtensionMessage implements Message { myPort.onMessage.addListener(handler); }); addUserScriptConnectionListener = null; + this.userScriptConnectionListenerReady = true; } catch { - // do nothing + this.userScriptConnectionListenerReady = false; } }; // Firefox 需要先得到 userScripts 权限才能进行 onUserScriptConnect 的监听 this.tryEnableUserScriptConnectionListener = () => { if (typeof chrome.runtime.onUserScriptConnect?.addListener === "function") { addUserScriptConnectionListener && addUserScriptConnectionListener(); + } else { + this.userScriptConnectionListenerReady = false; } }; // Chrome 在初始化时就能监听 @@ -139,7 +145,7 @@ export class ExtensionMessage implements Message { ) { this.tryEnableUserScriptConnectionListener(); this.tryEnableUserScriptMessageListener(); - sendResponse(true); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); } else { sendResponse(false); } @@ -164,21 +170,24 @@ export class ExtensionMessage implements Message { if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { this.tryEnableUserScriptConnectionListener(); this.tryEnableUserScriptMessageListener(); - sendResponse(true); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); return false; } if (typeof msg.action !== "string") return; return callback(msg, sendResponse, sender, "userScript"); }); addUserScriptMessageListener = null; + this.userScriptMessageListenerReady = true; } catch { - // do nothing + this.userScriptMessageListenerReady = false; } }; // Firefox 需要先得到 userScripts 权限才能进行 onUserScriptMessage 的监听 this.tryEnableUserScriptMessageListener = () => { if (typeof chrome.runtime.onUserScriptMessage?.addListener === "function") { addUserScriptMessageListener && addUserScriptMessageListener(); + } else { + this.userScriptMessageListenerReady = false; } }; // Chrome 在初始化时就能监听 diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index 9cfc3ea8b..8689a7424 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -46,14 +46,33 @@ describe("connectUserScriptChannel", () => { }); }); - it("does not open a port when the browser cannot enable USER_SCRIPT listeners", async () => { + it("returns no channel when the browser cannot enable any runtime port", async () => { const message = { sendMessage: vi.fn().mockResolvedValue(false), - connect: vi.fn(), + connect: vi.fn().mockRejectedValue(new Error("runtime.connect is unavailable")), } as unknown as Message; await expect(connectUserScriptChannel(message, "bootstrap-token", vi.fn())).resolves.toBeUndefined(); - expect(message.connect).not.toHaveBeenCalled(); + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + + it("uses a constrained extension-port fallback when dedicated listeners are unavailable", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); }); it("reports remote disconnects so the caller can reconnect natively", async () => { diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index 6a4466341..b3fdb30a4 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -21,11 +21,17 @@ export async function connectUserScriptChannel( world: UserScriptWorld = "USER_SCRIPT" ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); - if (enabled === false) return undefined; - const connection = await message.connect({ - action: "serviceWorker/runtime/registerUserScript", - data: { world, bootstrapToken }, - }); + let connection: MessageConnect; + try { + // 缺少专用 USER_SCRIPT 监听器时仍使用扩展原生端口;服务端会用文档绑定的令牌限制该降级路径。 + connection = await message.connect({ + action: "serviceWorker/runtime/registerUserScript", + data: enabled === false ? { world, bootstrapToken, transport: "extension" } : { world, bootstrapToken }, + }); + } catch (error) { + if (enabled !== false) throw error; + return undefined; + } connection.onMessage((packet) => onPacket(connection, packet)); if (onDisconnect) connection.onDisconnect(onDisconnect); connection.sendMessage({ action: "userScript/bootstrap" }); diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 1e696b3eb..b338c03d4 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1563,8 +1563,19 @@ describe("USER_SCRIPT native callbacks", () => { { ...connectionSender, getConnectOrigin: () => "extension" as const } ) ).toBe(false); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken, transport: "extension" }, + connectionSender + ) + ).toBe(false); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken, transport: "extension" }, + { ...connectionSender, getConnectOrigin: () => "extension" as const } + ) + ).toBe(true); expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT" }, connectionSender)).toBe(false); - expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken }, connectionSender)).toBe(true); const bootstrapHandler = onMessage.mock.calls[0]?.[0] as ((packet: TMessage) => void) | undefined; bootstrapHandler?.({ action: "userScript/bootstrap" }); expect(sendMessage).toHaveBeenCalledWith( @@ -1597,7 +1608,7 @@ describe("USER_SCRIPT native callbacks", () => { getSender: () => rawSender, getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), getConnect: () => undefined, - getConnectOrigin: () => "userScript" as const, + getConnectOrigin: () => "extension" as const, } ); expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 6d654e04c..486b2f021 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -141,7 +141,9 @@ type UserScriptSession = { tabId: number; frameId?: number; documentId?: string; + transport: "userScript" | "extension"; }; +type UserScriptBootstrap = Omit; const bgScriptStorageNames = new Set(); @@ -168,7 +170,7 @@ export class RuntimeService { documentId?: string; } >(); - private readonly userScriptBootstraps = new Map(); + private readonly userScriptBootstraps = new Map(); // 连接断开后保留当前文档的已验证资料,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 private readonly userScriptSessions = new Map(); // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. @@ -252,14 +254,17 @@ export class RuntimeService { return `${tabId}:${frameId ?? -1}:${documentId ?? ""}:${envTag}`; } - /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks. */ + /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks; fallback ports remain token-bound. */ registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { // bootstrap token 只允许对应 tab/frame/document 使用一次;documentId 缺失时以 URL 作为文档身份,并且必须覆盖本次下发的全部句柄。 - if (!sender.isType(GetSenderType.EXTCONNECT) || sender.getConnectOrigin?.() !== "userScript") return false; + if (!sender.isType(GetSenderType.EXTCONNECT)) return false; if (data === null || typeof data !== "object") return false; - const handshake = data as { world?: unknown; bootstrapToken?: unknown }; + const handshake = data as { world?: unknown; bootstrapToken?: unknown; transport?: unknown }; + const origin = sender.getConnectOrigin?.(); + const isExtensionFallback = origin === "extension" && handshake.transport === "extension"; + if (origin === "userScript" ? handshake.transport !== undefined : !isExtensionFallback) return false; if ( - Object.keys(data).length !== 2 || + Object.keys(data).length !== (isExtensionFallback ? 3 : 2) || typeof handshake.bootstrapToken !== "string" || handshake.bootstrapToken.length === 0 || handshake.bootstrapToken.length > 256 @@ -304,7 +309,8 @@ export class RuntimeService { const frameId = source.frameId; const documentId = source.documentId; const key = this.userScriptConnectionKey(tabId, frameId, documentId, bootstrap.envTag); - this.userScriptSessions.set(key, bootstrap); + const session = { ...bootstrap, transport: isExtensionFallback ? ("extension" as const) : ("userScript" as const) }; + this.userScriptSessions.set(key, session); this.userScriptBootstraps.delete(handshake.bootstrapToken); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); @@ -344,7 +350,7 @@ export class RuntimeService { } reconnectUserScript(data: unknown, sender: IGetSender): { bootstrapToken: string } | undefined { - if (!sender.isType(GetSenderType.RUNTIME) || sender.getConnectOrigin?.() !== "userScript") { + if (!sender.isType(GetSenderType.RUNTIME)) { return undefined; } if ( @@ -377,6 +383,7 @@ export class RuntimeService { } } if (!key || !session) return undefined; + if (sender.getConnectOrigin?.() !== session.transport) return undefined; for (const script of session.scripts) { const handle = script.executionHandle; const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; From 901016d6b78af4941abafc48816c68cadc0d1fe3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:38:57 +0900 Subject: [PATCH 074/106] =?UTF-8?q?=F0=9F=94=92=20queue=20USER=5FSCRIPT=20?= =?UTF-8?q?value=20updates=20across=20reconnects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/runtime.test.ts | 112 ++++++++++++++++++ src/app/service/service_worker/runtime.ts | 87 +++++++++++++- 2 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index b338c03d4..4a07fbe65 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1516,6 +1516,118 @@ describe("USER_SCRIPT native callbacks", () => { }); }); + it("queues USER_SCRIPT value updates until a reconnect finishes its bootstrap", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "queued-content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [], + contentScriptList: [script], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const makeConnection = () => + ({ + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + }) as unknown as MessageConnect; + const firstConnection = makeConnection(); + const sender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => firstConnection, + getConnectOrigin: () => "userScript" as const, + }; + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + const contentBootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect(contentBootstrapToken).toEqual(expect.any(String)); + expect( + runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken: contentBootstrapToken }, sender) + ).toBe(true); + + const update = { + uuid: script.uuid, + storageName: getStorageName(script), + entries: [["beforeReconnect", [0, "new"], [0, "old"]]], + sender: { runFlag: "remote", tabId: 42 }, + valueUpdated: true, + }; + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", update); + expect(firstConnection.sendMessage).not.toHaveBeenCalled(); + + const firstBootstrapHandler = (firstConnection.onMessage as ReturnType).mock.calls[0][0] as ( + packet: TMessage + ) => void; + firstBootstrapHandler({ action: "userScript/bootstrap" }); + expect(firstConnection.sendMessage).toHaveBeenCalledTimes(2); + expect(firstConnection.sendMessage).toHaveBeenLastCalledWith({ + action: "content/runtime/valueUpdate", + data: update, + }); + + const disconnectHandler = (firstConnection.onDisconnect as ReturnType).mock.calls[0][0] as ( + isSelfDisconnected: boolean + ) => void; + disconnectHandler(false); + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", { + ...update, + entries: [["afterReconnect", [0, "next"], [0, "old-next"]]], + }); + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", { + ...update, + entries: [["afterReconnectAgain", [0, "latest"], [0, "old-latest"]]], + }); + const reconnect = runtime.reconnectUserScript( + { reconnectToken: contentBootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ); + expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); + + const secondConnection = makeConnection(); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken: reconnect?.bootstrapToken }, + { ...sender, getConnect: () => secondConnection } + ) + ).toBe(true); + const secondBootstrapHandler = (secondConnection.onMessage as ReturnType).mock.calls[0][0] as ( + packet: TMessage + ) => void; + secondBootstrapHandler({ action: "userScript/bootstrap" }); + + expect(secondConnection.sendMessage).toHaveBeenCalledTimes(2); + expect(secondConnection.sendMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: "content/runtime/valueUpdate", + data: expect.objectContaining({ + entries: [ + ["afterReconnect", [0, "next"], [0, "old-next"]], + ["afterReconnectAgain", [0, "latest"], [0, "old-latest"]], + ], + }), + }) + ); + }); + it("只向当前文档中声明了对应脚本或 storageName 的连接投递更新", async () => { const { runtime } = _createRuntimeContext(); const script = _createScriptRunResource( diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 486b2f021..d0233c5b8 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -142,6 +142,8 @@ type UserScriptSession = { frameId?: number; documentId?: string; transport: "userScript" | "extension"; + // 断线窗口内按 storageName 合并值更新,重连握手完成后再投递。 + pendingValueUpdates: Map; }; type UserScriptBootstrap = Omit; @@ -168,10 +170,11 @@ export class RuntimeService { tabId: number; frameId?: number; documentId?: string; + ready: boolean; } >(); private readonly userScriptBootstraps = new Map(); - // 连接断开后保留当前文档的已验证资料,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 + // 连接断开后保留当前文档的已验证资料与待投递值更新,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 private readonly userScriptSessions = new Map(); // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. private readonly pageLoadSequences = new Map(); @@ -314,7 +317,7 @@ export class RuntimeService { this.userScriptBootstraps.delete(handshake.bootstrapToken); const previous = this.userScriptConnections.get(key); if (previous) previous.connection.disconnect(true); - const entry = { connection, handles, envTag: bootstrap.envTag, tabId, frameId, documentId }; + const entry = { connection, handles, envTag: bootstrap.envTag, tabId, frameId, documentId, ready: false }; this.userScriptConnections.set(key, entry); connection.onDisconnect(() => { if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); @@ -342,6 +345,8 @@ export class RuntimeService { action: `${bootstrap.envTag === "it" ? "inject" : "content"}/pageLoad`, data: pageLoadData, }); + entry.ready = true; + this.flushPendingUserScriptValueUpdates(key, entry); } catch { this.userScriptConnections.delete(key); } @@ -414,11 +419,57 @@ export class RuntimeService { return { bootstrapToken }; } + private queuePendingUserScriptValueUpdate(key: string, data: ValueUpdateDataEncoded): void { + const session = this.userScriptSessions.get(key); + if (!session) return; + const previous = session.pendingValueUpdates.get(data.storageName); + if (!previous) { + session.pendingValueUpdates.set(data.storageName, data); + return; + } + const entries: ValueUpdateDataEncoded["entries"] = previous.entries.map((entry) => [entry[0], entry[1], entry[2]]); + const entryIndexes = new Map(); + for (let index = 0; index < entries.length; index += 1) entryIndexes.set(entries[index][0], index); + for (const entry of data.entries) { + const index = entryIndexes.get(entry[0]); + if (index === undefined) { + entryIndexes.set(entry[0], entries.length); + entries.push([entry[0], entry[1], entry[2]]); + } else { + entries[index] = [entry[0], entry[1], entries[index][2]]; + } + } + session.pendingValueUpdates.set(data.storageName, { + ...data, + entries, + valueUpdated: previous.valueUpdated || data.valueUpdated, + }); + } + + private flushPendingUserScriptValueUpdates( + key: string, + entry: { connection: MessageConnect; envTag: "it" | "ct" } + ): void { + const session = this.userScriptSessions.get(key); + if (!session) return; + for (const [storageName, data] of session.pendingValueUpdates) { + entry.connection.sendMessage({ + action: `${entry.envTag === "it" ? "inject" : "content"}/runtime/valueUpdate`, + data, + }); + session.pendingValueUpdates.delete(storageName); + } + } + private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { const dataRecord = typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; const targetUuid = action === "runtime/emitEvent" ? dataRecord?.uuid : undefined; const targetStorageName = action === "runtime/valueUpdate" ? dataRecord?.storageName : undefined; + const valueUpdate = + action === "runtime/valueUpdate" && typeof dataRecord?.storageName === "string" + ? (data as ValueUpdateDataEncoded) + : undefined; // 先按页面定位,再按句柄对应的脚本或 storageName 过滤,避免跨脚本广播私有回调。 for (const [key, entry] of this.userScriptConnections) { if ( @@ -442,11 +493,42 @@ export class RuntimeService { } } if (!bindingMatches) continue; + if (!entry.ready) { + if (valueUpdate) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + continue; + } try { entry.connection.sendMessage({ action: `${entry.envTag === "it" ? "inject" : "content"}/${action}`, data }); } catch { this.userScriptConnections.delete(key); + if (valueUpdate) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + } + } + if (!valueUpdate) return; + for (const [key, session] of this.userScriptSessions) { + if (this.userScriptConnections.has(key)) continue; + if ( + to && + (session.tabId !== to.tabId || + (to.frameId !== undefined && session.frameId !== to.frameId) || + (to.documentId !== undefined && session.documentId !== to.documentId)) + ) { + continue; + } + let bindingMatches = false; + for (const script of session.scripts) { + const handle = script.executionHandle; + const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; + if ( + binding && + ((targetUuid !== undefined && targetUuid === binding.uuid) || + (targetStorageName !== undefined && targetStorageName === binding.storageName)) + ) { + bindingMatches = true; + break; + } } + if (bindingMatches) this.queuePendingUserScriptValueUpdate(key, valueUpdate); } } @@ -1725,6 +1807,7 @@ export class RuntimeService { tabId, frameId, documentId: chromeSender.documentId, + pendingValueUpdates: new Map(), }); return token; }; From 643af56bca0f5b8ee5c3634334967b2587534e0f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:45:57 +0900 Subject: [PATCH 075/106] =?UTF-8?q?=F0=9F=94=92=20protect=20GM=20value=20s?= =?UTF-8?q?tores=20from=20prototype=20keys?= 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 | 17 +++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 21 +++++++++++++------ src/app/service/service_worker/value.test.ts | 21 +++++++++++++++++++ src/app/service/service_worker/value.ts | 13 ++++++++++-- 4 files changed, 64 insertions(+), 8 deletions(-) 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 932a02b57..7deda703d 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -705,6 +705,23 @@ describe.concurrent("GM_menu", () => { }); describe.concurrent("GM_value", () => { + it("stores __proto__ as a value key instead of changing the value store prototype", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const stored = { leaked: "secret" }; + + api.GM_setValue(api, "__proto__", stored); + + expect(Object.prototype.hasOwnProperty.call(script.value, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(script.value)).toBe(Object.prototype); + expect(api.GM_getValue(api, "__proto__")).toEqual(stored); + expect(api.GM_getValue(api, "leaked")).toBeUndefined(); + }); + it.concurrent("GM_setValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 1b9b94da0..9c3a7ee4f 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -76,6 +76,15 @@ const copyOwnEnumerableDataProperties = (value: object): Record // 回调表不暴露 Map 原型,避免页面改写 Map 方法后影响值更新确认。 const valueChangePromiseMap: Record void> = Object.create(null); +const setOwnValue = (store: Record, key: string, value: any): void => { + Native.objectDefineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + // 通知 ID 只属于对应 GM context;WeakMap 不让脚本结束后残留监听状态。 const notificationTagMaps = new Native.WeakMap>(); @@ -254,11 +263,11 @@ class GM_Base implements IGM_Base { const oldValue = decodeRValue(rTyped2); // 触发,并更新值 if (value === undefined) { - if (valueStore[key] !== undefined) { + if (Native.objectHasOwn(valueStore, key)) { delete valueStore[key]; } } else { - valueStore[key] = value; + setOwnValue(valueStore, key, value); } // 监听器属于脚本,传副本避免回调修改 GM 存储或跨 context 共享对象。 const listenerValue = value && typeof value === "object" ? customClone(value) : value; @@ -319,7 +328,7 @@ export default class GMApi extends GM_Base { static _GM_getValue(a: GMApi, key: string, defaultValue?: any) { if (!a.scriptRes) return undefined; - const ret = a.scriptRes.value[key]; + const ret = Native.objectHasOwn(a.scriptRes.value, key) ? a.scriptRes.value[key] : undefined; if (ret !== undefined) { if (ret && typeof ret === "object") { return customClone(ret)!; @@ -365,7 +374,7 @@ export default class GMApi extends GM_Base { value = customClone(value); } // customClone 可能返回 undefined - a.scriptRes.value[key] = value; + setOwnValue(a.scriptRes.value, key, value); if (value === undefined) { a.sendMessage("GM_setValue", [id, key]); } else { @@ -412,7 +421,7 @@ export default class GMApi extends GM_Base { value_ = customClone(value_); } // customClone 可能返回 undefined - valueStore[key] = value_; + setOwnValue(valueStore, key, value_); } // 避免undefined 等空值流失,先进行映射处理 keyValuePairs[keyValuePairs.length] = [key, encodeRValue(value_)]; @@ -485,7 +494,7 @@ export default class GMApi extends GM_Base { // Handle array of keys (e.g., ['foo', 'bar']) for (let index = 0; index < keysOrDefaults.length; index++) { const key = keysOrDefaults[index]; - if (key in ctx.scriptRes.value) { + if (Native.objectHasOwn(ctx.scriptRes.value, key)) { // 对object的value进行一次转化 let value = ctx.scriptRes.value[key]; if (value && typeof value === "object") { diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index 7dcc2ae9a..c42ff5a2e 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -100,6 +100,27 @@ describe("ValueService - setValue 方法测试", () => { vi.restoreAllMocks(); }); + it("persists __proto__ as an own value key without polluting inherited values", async () => { + const mockScript = createMockScript(); + const stored = { leaked: "secret" }; + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + + await valueService.setValues({ + uuid: mockScript.uuid, + keyValuePairs: [["__proto__", encodeRValue(stored)]], + valueSender: createMockValueSender(), + isReplace: false, + }); + + const savedData = vi.mocked(mockValueDAO.save).mock.calls[0][1].data; + expect(Object.prototype.hasOwnProperty.call(savedData, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(savedData)).toBe(Object.prototype); + expect(savedData.__proto__).toEqual(stored); + expect((savedData as Record).leaked).toBeUndefined(); + }); + it("应该成功设置新脚本的值", async () => { // 准备测试数据 const mockScript = createMockScript(); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 6f5af7ba5..5855cc43a 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -15,6 +15,15 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import type { TKeyValuePair } from "@App/pkg/utils/message_value"; import { decodeRValue, R_UNDEFINED, encodeRValue } from "@App/pkg/utils/message_value"; +const setOwnValue = (store: Record, key: string, value: any): void => { + Object.defineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + export type TSetValuesParams = { uuid: string; id?: string; @@ -107,7 +116,7 @@ export class ValueService { for (const [key, rTyped1] of keyValuePairs) { const value = decodeRValue(rTyped1); if (value !== undefined) { - dataModel[key] = value; + setOwnValue(dataModel, key, value); entries.push([key, rTyped1, R_UNDEFINED]); } } @@ -134,7 +143,7 @@ export class ValueService { if (value === undefined) { delete dataModel[key]; } else { - dataModel[key] = value; + setOwnValue(dataModel, key, value); } const rTyped2 = encodeRValue(oldValue); entries.push([key, rTyped1, rTyped2]); From 8f4cf28e239063404986f980bd899c03ff350384 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:50:38 +0900 Subject: [PATCH 076/106] =?UTF-8?q?=F0=9F=94=92=20reject=20hostile=20page?= =?UTF-8?q?=20message=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.test.ts | 57 ++++++++++++++++++++++ packages/message/page_message.ts | 68 +++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/packages/message/page_message.test.ts b/packages/message/page_message.test.ts index 7a2a4a72e..39ea01396 100644 --- a/packages/message/page_message.test.ts +++ b/packages/message/page_message.test.ts @@ -67,4 +67,61 @@ describe("PageMessage", () => { expect(target.handlers.size).toBe(handlerCount - 1); inject.dispose(); }); + + it("ignores envelopes with accessor fields without executing the accessor", () => { + const target = createWindow(); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onMessage(received); + const envelope: Record = { + channel: "page-message-test", + source: "scripting", + target: "inject", + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + const handler = [...target.handlers][0]; + expect(() => handler({ source: target, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("ignores proxy envelopes whose own-key inspection is hostile", () => { + const target = createWindow(); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onMessage(received); + const envelope = new Proxy( + { + channel: "page-message-test", + source: "scripting", + target: "inject", + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + const handler = [...target.handlers][0]; + expect(() => handler({ source: target, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); }); diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts index c1be57325..14c8ed308 100644 --- a/packages/message/page_message.ts +++ b/packages/message/page_message.ts @@ -30,6 +30,69 @@ const bindNative = any>(fn: T, receiver: any): T const listenerMgr = new EventEmitter(); +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const PAGE_MESSAGE_KEYS = ["channel", "source", "target", "messageId", "type", "data"] as const; + +const parsePageMessageBody = (value: unknown): PageMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + return undefined; + } + if (keys.length !== PAGE_MESSAGE_KEYS.length) return undefined; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + if (typeof key === "string") { + for (let expectedIndex = 0; expectedIndex < PAGE_MESSAGE_KEYS.length; expectedIndex += 1) { + if (PAGE_MESSAGE_KEYS[expectedIndex] === key) { + known = true; + break; + } + } + } + if (!known) { + return undefined; + } + } + + let fields: PropertyDescriptor[]; + try { + fields = []; + for (let index = 0; index < PAGE_MESSAGE_KEYS.length; index += 1) { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, PAGE_MESSAGE_KEYS[index]); + if (!descriptor || !("value" in descriptor)) return undefined; + fields[fields.length] = descriptor; + } + } catch { + return undefined; + } + const channel = fields[0].value; + const source = fields[1].value; + const target = fields[2].value; + const messageId = fields[3].value; + const type = fields[4].value; + const data = fields[5].value; + if ( + typeof channel !== "string" || + (source !== "scripting" && source !== "inject") || + (target !== "scripting" && target !== "inject") || + typeof messageId !== "string" || + (type !== "sendMessage" && + type !== "respMessage" && + type !== "connect" && + type !== "disconnect" && + type !== "connectMessage") + ) { + return undefined; + } + return { channel, source, target, messageId, type, data } as PageMessageBody; +}; + const otherRole = (role: PageMessageRole): PageMessageRole => (role === "scripting" ? "inject" : "scripting"); class PageMessageConnect implements MessageConnect { @@ -120,14 +183,13 @@ export class PageMessage implements Message { this.targetRole = otherRole(role); this.messageHandler = (event: MessageEvent) => { if (event.source !== null && event.source !== sourceWindow) return; - const body = event.data as Partial | null; + const body = parsePageMessageBody(event.data); if ( !body || body.channel !== this.channel || body.target !== this.role || body.source !== this.targetRole || - typeof body.messageId !== "string" || - typeof body.type !== "string" + typeof body.messageId !== "string" ) { return; } From 5b253102eeb3f84bbf4b06dd711cbf5d527a7f66 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:53:25 +0900 Subject: [PATCH 077/106] =?UTF-8?q?=F0=9F=94=92=20validate=20sandbox=20mes?= =?UTF-8?q?sage=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/window_message.test.ts | 57 ++++++++++++++++++++++ packages/message/window_message.ts | 63 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 00be4f8d7..0262c8800 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -210,6 +210,63 @@ describe("WindowMessage.connect", () => { }); }); +describe("WindowMessage envelope validation", () => { + it("ignores accessor envelopes without executing their getters", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const sourceWindow = { + addEventListener: vi.fn((_event: string, handler: (event: MessageEvent) => void) => { + messageHandler = handler; + }), + } as unknown as Window; + const targetWindow = {} as unknown as Window; + const windowMessage = new WindowMessage(sourceWindow, targetWindow); + const received = vi.fn(); + windowMessage.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "offscreen/ping" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => messageHandler!({ source: targetWindow, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + }); + + it("ignores proxy envelopes whose own-key inspection throws", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const sourceWindow = { + addEventListener: vi.fn((_event: string, handler: (event: MessageEvent) => void) => { + messageHandler = handler; + }), + } as unknown as Window; + const targetWindow = {} as unknown as Window; + const windowMessage = new WindowMessage(sourceWindow, targetWindow); + const received = vi.fn(); + windowMessage.onMessage(received); + const envelope = new Proxy( + { messageId: "hostile", type: "sendMessage", data: { action: "offscreen/ping" } }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + expect(() => messageHandler!({ source: targetWindow, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + }); +}); + // 单测重点:target 支持传入惰性求值函数,避免在 Firefox sandbox iframe 尚处于初始 about:blank // 阶段就缓存 contentWindow 快照——导航到真正的 sandbox 页面后,浏览器是否仍保证该快照与 // 事件的 e.source 全等属于实现细节,不可依赖;每次发送/比对都应重新读取当前值。 diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 9946bd089..aba07fd61 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -32,6 +32,60 @@ export type WindowMessageBody = { data: T | null; // 消息数据 }; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const WINDOW_MESSAGE_KEYS = ["messageId", "type", "data"] as const; + +const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + return undefined; + } + if (keys.length !== WINDOW_MESSAGE_KEYS.length) return undefined; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + if (typeof key === "string") { + for (let expectedIndex = 0; expectedIndex < WINDOW_MESSAGE_KEYS.length; expectedIndex += 1) { + if (WINDOW_MESSAGE_KEYS[expectedIndex] === key) { + known = true; + break; + } + } + } + if (!known) return undefined; + } + + let fields: PropertyDescriptor[]; + try { + fields = []; + for (let index = 0; index < WINDOW_MESSAGE_KEYS.length; index += 1) { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, WINDOW_MESSAGE_KEYS[index]); + if (!descriptor || !("value" in descriptor)) return undefined; + fields[fields.length] = descriptor; + } + } catch { + return undefined; + } + const messageId = fields[0].value; + const type = fields[1].value; + if ( + typeof messageId !== "string" || + (type !== "sendMessage" && + type !== "respMessage" && + type !== "connect" && + type !== "disconnect" && + type !== "connectMessage") + ) { + return undefined; + } + return { messageId, type, data: fields[2].value } as WindowMessageBody; +}; + export class WindowMessage implements Message { EE = new EventEmitter(); @@ -78,6 +132,9 @@ export class WindowMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 @@ -257,6 +314,9 @@ export class ServiceWorkerMessageSend implements Message { } messageHandle(data: WindowMessageBody, source?: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage" && source) { // 接收到来自offscreen的请求消息 @@ -358,6 +418,9 @@ export class ServiceWorkerClientMessage implements Message { } messageHandle(data: WindowMessageBody, source?: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 只处理响应类消息,请求类消息由WindowMessage处理 if (data.type === "sendMessage" && source) { this.EE.emit( From 898fcc440f295b55f49378f69b2e8d10fa061064 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:55:10 +0900 Subject: [PATCH 078/106] =?UTF-8?q?=F0=9F=94=92=20validate=20custom=20even?= =?UTF-8?q?t=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/custom_event_message.test.ts | 26 ++++++++++++++++++- packages/message/custom_event_message.ts | 10 ++++++- packages/message/window_message.ts | 2 +- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/message/custom_event_message.test.ts b/packages/message/custom_event_message.test.ts index 190d7f3cb..290e79a5c 100644 --- a/packages/message/custom_event_message.test.ts +++ b/packages/message/custom_event_message.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CustomEventMessage } from "./custom_event_message"; import { createMouseEvent, pageDispatchEvent } from "@Packages/message/common"; @@ -17,6 +17,30 @@ function createMessagePair() { } describe("CustomEventMessage relatedTarget lifecycle", () => { + it("ignores accessor envelopes without executing their getters", () => { + const receiver = new CustomEventMessage(`custom-event-message-test-${++flagCounter}`, true, ""); + const received = vi.fn(); + receiver.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "custom-event-message-test/hostile" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => receiver.messageHandle(envelope as any, { postMessage: vi.fn() })).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + }); + it("stores a received target on the receiving message until it is consumed", () => { const { sender, receiver } = createMessagePair(); const target = document.createElement("div"); diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 63b33038a..dda181001 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -1,6 +1,11 @@ import type { Message, MessageConnect, RuntimeMessageSender, TMessage } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import { type PostMessage, type WindowMessageBody, WindowMessageConnect } from "./window_message"; +import { + parseWindowMessageBody, + type PostMessage, + type WindowMessageBody, + WindowMessageConnect, +} from "./window_message"; import EventEmitter from "eventemitter3"; import { DefinedFlags } from "@App/app/service/service_worker/runtime.consts"; import { @@ -78,6 +83,9 @@ export class CustomEventMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index aba07fd61..40cd2d8e5 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -36,7 +36,7 @@ const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const WINDOW_MESSAGE_KEYS = ["messageId", "type", "data"] as const; -const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { +export const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; let keys: (string | symbol)[]; From 35ce58184082945a741595444653a21bd3fd4efa Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:58:22 +0900 Subject: [PATCH 079/106] =?UTF-8?q?=F0=9F=94=92=20guard=20server=20action?= =?UTF-8?q?=20dispatch=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/server.test.ts | 38 +++++++++++++++++++++++++++++ packages/message/server.ts | 42 ++++++++++++++++++++++++++------- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index 7da2268ee..ac6959811 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -35,6 +35,44 @@ afterEach(() => { }); describe("Server", () => { + it("ignores message envelopes with accessor actions without executing the accessor", () => { + const handler = vi.fn(); + server.on("on-hostile", handler); + const message: Record = { data: {} }; + let accessed = false; + Object.defineProperty(message, "action", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => inboundMessage.EE.emit("message", message, vi.fn(), {})).not.toThrow(); + expect(accessed).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it("ignores message envelopes with accessor data without executing the accessor", () => { + const handler = vi.fn(); + server.on("on-hostile-data", handler); + const message: Record = { action: "api/on-hostile-data" }; + let accessed = false; + Object.defineProperty(message, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => inboundMessage.EE.emit("message", message, vi.fn(), {})).not.toThrow(); + expect(accessed).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + it("应该在消息和长连接转发中都应用参数转换", async () => { const transformed: unknown[] = []; const targetFlag = `${uuidv4()}::target`; diff --git a/packages/message/server.ts b/packages/message/server.ts index 70a34d28d..485fb9014 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -14,10 +14,28 @@ import Logger from "@App/app/logger/logger"; const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; // 转发监听器会跨 context 保存一段时间,绑定时固定原生 bind,避免页面改写原型。 const bindNative = any>(fn: T, receiver: any): T => nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; +type ParsedServerMessage = { action: string; data?: unknown }; + +const parseServerMessage = (value: unknown): ParsedServerMessage | undefined => { + if (value === null || typeof value !== "object") return undefined; + try { + const actionDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "action"); + if (!actionDescriptor || !("value" in actionDescriptor) || typeof actionDescriptor.value !== "string") { + return undefined; + } + const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if (dataDescriptor && !("value" in dataDescriptor)) return undefined; + return { action: actionDescriptor.value, data: dataDescriptor?.value }; + } catch { + return undefined; + } +}; + export const enum GetSenderType { CONNECT = 1, EXTCONNECT = 1 | 2, @@ -166,10 +184,11 @@ export class Server { if (this.enableConnect) { msgReceiverList.forEach((msg) => { msg.onConnect((msg: TMessage, con: MessageConnect) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onConnect", { msg }); - if (msg.action?.startsWith(this.prefix)) { - return this.connectHandle(msg.action.slice(this.prefix.length + 1), msg.data, con); + const parsed = parseServerMessage(msg); + if (!parsed) return; + this.logger.trace("server onConnect", { action: parsed.action }); + if (parsed.action.startsWith(this.prefix)) { + return this.connectHandle(parsed.action.slice(this.prefix.length + 1), parsed.data, con); } return false; }); @@ -178,10 +197,17 @@ export class Server { msgReceiverList.forEach((msg) => { msg.onMessage((msg: TMessage, sendResponse, sender, origin) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onMessage", { msg: msg as any }); - if (msg.action?.startsWith(this.prefix)) { - return this.messageHandle(msg.action.slice(this.prefix.length + 1), msg.data, sendResponse, sender, origin); + const parsed = parseServerMessage(msg); + if (!parsed) return; + this.logger.trace("server onMessage", { action: parsed.action }); + if (parsed.action.startsWith(this.prefix)) { + return this.messageHandle( + parsed.action.slice(this.prefix.length + 1), + parsed.data, + sendResponse, + sender, + origin + ); } }); return false; From 276196c7c53d32514d6f848f7232d39519e20d31 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:04:22 +0900 Subject: [PATCH 080/106] =?UTF-8?q?=F0=9F=94=92=20inspect=20collection=20p?= =?UTF-8?q?ayload=20entries=20before=20cloning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 29 ++++++++++++ .../service/content/script_runtime.test.ts | 44 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index e05fd5073..ee2e73c6b 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -119,6 +119,31 @@ export const customClone = (o: any) => { const isDataOnly = (value: object): boolean => { if (seen.has(value)) return true; seen.set(value, true); + + // Map/Set 条目不在自有属性中,必须先检查,避免 structuredClone 遍历时触发嵌套访问器。 + try { + let valid = true; + nativeReflectApply(nativeMapForEach, value as Map, [ + (key: unknown, entry: unknown) => { + if (valid && (!isDataOnlyValue(key) || !isDataOnlyValue(entry))) valid = false; + }, + ]); + return valid; + } catch { + // 不是 Map,继续检查普通自有属性。 + } + try { + let valid = true; + nativeReflectApply(nativeSetForEach, value as Set, [ + (entry: unknown) => { + if (valid && !isDataOnlyValue(entry)) valid = false; + }, + ]); + return valid; + } catch { + // 不是 Set,继续检查普通自有属性。 + } + let keys: PropertyKey[]; try { keys = nativeReflectOwnKeys(value); @@ -143,6 +168,10 @@ export const customClone = (o: any) => { } return true; }; + const isDataOnlyValue = (value: unknown): boolean => { + if (value === null || typeof value !== "object") return true; + return isDataOnly(value); + }; if (!isDataOnly(o)) return undefined; if (hasNativeStructuredClone) { diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts index d52b7ae59..3945f3772 100644 --- a/src/app/service/content/script_runtime.test.ts +++ b/src/app/service/content/script_runtime.test.ts @@ -134,6 +134,50 @@ describe("ScriptRuntime inject page bootstrap", () => { expect(executor.emitEvent).not.toHaveBeenCalled(); }); + it("rejects accessors nested in collection callback payloads", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + const eventData = { + uuid: "script", + event: "menuClick", + eventId: "1", + data: new Map([["nested", nested]]), + }; + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in set callback payloads", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + const eventData = { + uuid: "script", + event: "menuClick", + eventId: "1", + data: new Set([nested]), + }; + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + it("clones valid callback and value-update DTOs before dispatch", () => { const { handlers, server } = makeServer(); const executor = makeExecutor(); From c783a7a20109624b4ffc5599f04505cf224b3514 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:10:53 +0900 Subject: [PATCH 081/106] =?UTF-8?q?=F0=9F=94=92=20validate=20collection=20?= =?UTF-8?q?RPC=20payloads=20without=20accessors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 45 +++++++++++++++++ src/app/service/content/page_rpc.ts | 62 +++++++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 9274e5d70..2d7dee361 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -236,6 +236,51 @@ describe("page GM RPC", () => { ).toThrow(PageRpcError); }); + it("rejects accessors nested in collection RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "collection", handle, api: "GM_getValue", params: [new Map([["nested", nested]])] }, + registry + ) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in set RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "set", handle, api: "GM_getValue", params: [new Set([nested])] }, + registry + ) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("does not execute a Symbol.toStringTag accessor while validating RPC values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "Blob"); + const nested = Object.create(null) as Record; + Object.defineProperty(nested, Symbol.toStringTag, { configurable: true, get: getter }); + + expect(() => + validatePageGMRequest({ version: 1, requestId: "tag", handle, api: "GM_getValue", params: [nested] }, registry) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + it("keeps validation on captured intrinsics after page prototype hooks", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 4106e0fdb..813b84277 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -8,6 +8,8 @@ const MAX_REQUEST_ID_LENGTH = 256; const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; const nativeObjectToString = Object.prototype.toString; +const nativeMapForEach = Map.prototype.forEach; +const nativeSetForEach = Set.prototype.forEach; const EXTENSION_PROTOCOLS = new Native.Set(["chrome-extension:", "moz-extension:"]); const nativeReflectOwnKeys = Native.reflectOwnKeys; const nativeObjectGetOwnPropertyDescriptor = Native.objectGetOwnPropertyDescriptor; @@ -201,14 +203,70 @@ const ownData = (value: object, key: PropertyKey): unknown => { return descriptor.value; }; +const isBlobLike = (value: object): boolean => { + let current: object | null = value; + while (current !== null) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = nativeObjectGetOwnPropertyDescriptor(current, Symbol.toStringTag); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + if (descriptor) { + if (!("value" in descriptor)) throw new PageRpcError("page RPC values cannot contain accessor properties"); + return descriptor.value === "Blob"; + } + try { + current = Native.objectGetPrototypeOf(current); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + } + return false; +}; + const assertDataOnly = (value: unknown, seen: Set): void => { // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 if (value === null || typeof value !== "object") return; - // Blob 的内部槽由浏览器管理,不能把其 symbol/accessor 细节当作 DTO 字段遍历。 - if (nativeBlob && (value instanceof nativeBlob || nativeObjectToString.call(value) === "[object Blob]")) return; if (seen.has(value)) return; seen.add(value); + // Blob 的内部槽由浏览器管理;只检查可由页面添加的字符串属性,忽略其内部 symbol 属性。 + if (nativeBlob && (value instanceof nativeBlob || isBlobLike(value))) { + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key === "string") assertDataOnly(ownData(value, key), seen); + } + return; + } + + // Map/Set 条目不在自有属性中,必须先检查,避免 structuredClone 遍历时触发嵌套访问器。 + try { + nativeReflectApply(nativeMapForEach, value as Map, [ + (key: unknown, entry: unknown) => { + assertDataOnly(key, seen); + assertDataOnly(entry, seen); + }, + ]); + return; + } catch (error) { + if (error instanceof PageRpcError) throw error; + // 不是 Map,继续检查普通自有属性。 + } + try { + nativeReflectApply(nativeSetForEach, value as Set, [(entry: unknown) => assertDataOnly(entry, seen)]); + return; + } catch (error) { + if (error instanceof PageRpcError) throw error; + // 不是 Set,继续检查普通自有属性。 + } + let keys: (string | symbol)[]; try { keys = nativeReflectOwnKeys(value); From 671c4453d091fb939c67825dd0082cbd481debf8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:22:46 +0900 Subject: [PATCH 082/106] =?UTF-8?q?=F0=9F=94=92=20remove=20live=20document?= =?UTF-8?q?=20references=20from=20CAT=20fetch?= 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 | 47 +++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 4 +- src/app/service/content/gm_api/gm_xhr.ts | 32 +++++++++++-- src/app/service/content/scripting.test.ts | 11 ++++- src/app/service/content/scripting.ts | 34 +++++++++----- src/scripting.ts | 3 +- 6 files changed, 109 insertions(+), 22 deletions(-) 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 7deda703d..d9b748321 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -8,6 +8,7 @@ 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"; +import { parseSerializedDocumentResponse } from "./gm_xhr"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -113,6 +114,52 @@ describe("early-start page RPC", () => { }); }); +describe("CAT_fetchDocument", () => { + it("rebuilds documents from a data-only response instead of a relatedTarget reference", async () => { + const script = Object.assign({}, scriptRes, { + executionEnvTag: "it", + metadata: { grant: ["CAT_fetchDocument"] }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ + code: 0, + data: { + text: '
ok
', + contentType: "text/html", + }, + }); + const api = new GMApi("scripting", { sendMessage } as unknown as Message, {} as Message, script); + + const document = await api.CAT_fetchDocument(api, "https://example.test/document"); + + expect(document?.querySelector("main")?.getAttribute("data-source")).toBe("serialized"); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "scripting/runtime/gmApi", + data: expect.objectContaining({ api: "CAT_fetchDocument", params: ["https://example.test/document", false] }), + }) + ); + }); + + it("does not execute accessors in a forged serialized response", () => { + const getter = vi.fn(() => "secret"); + const data = { contentType: "text/html" } as Record; + Object.defineProperty(data, "text", { configurable: true, enumerable: true, get: getter }); + + expect(parseSerializedDocumentResponse(data)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + + const proxy = new Proxy( + { text: "", contentType: "text/html" }, + { + getOwnPropertyDescriptor: () => { + throw new Error("proxy trap"); + }, + } + ); + expect(parseSerializedDocumentResponse(proxy)).toBeUndefined(); + }); +}); + const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({ url, content, diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 9c3a7ee4f..b81f363ca 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -25,7 +25,7 @@ import { ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; import type { ContextType } from "./gm_xhr"; -import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; +import { convObjectToURL, GM_xmlhttpRequest, parseSerializedDocumentResponse, toBlobURL } from "./gm_xhr"; // 导入 CAT Agent API 以触发装饰器注册 // 注意:不能使用 import "./cat_agent",sideEffects 配置会导致 tree-shaking 移除纯副作用导入 import CATAgentApi from "./cat_agent"; @@ -650,7 +650,7 @@ export default class GMApi extends GM_Base { }); } - return urlToDocumentInContentPage(ctx, url, isContentEnv); + return parseSerializedDocumentResponse(await ctx.sendMessage("CAT_fetchDocument", [`${url}`, isContentEnv])); } static _GM_cookie( diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index e24a7f113..42a2ce6e7 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -1,5 +1,4 @@ import { Native } from "../global"; -import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type GMApi from "./gm_api"; import { dataEncode } from "@App/pkg/utils/xhr/xhr_data"; import type { MessageConnect, TMessage } from "@Packages/message/types"; @@ -113,10 +112,33 @@ export const convObjectToURL = async (object: string | URL | Blob | File | undef return url; }; -export const urlToDocumentInContentPage = async (a: GMApi, url: string, isContent: boolean) => { - // url (e.g. blob url) -> XMLHttpRequest (CONTENT) -> Document (CONTENT) - const nodeId = await a.sendMessage("CAT_fetchDocument", [`${url}`, isContent]); - return (a.message).getAndDelRelatedTarget(nodeId) as Document; +export type SerializedDocumentResponse = { + text: string; + contentType: string; +}; + +const readDataProperty = (value: object, key: string): unknown => { + try { + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +}; + +export const parseSerializedDocumentResponse = (value: unknown): Document | undefined => { + if (value === null || typeof value !== "object") return undefined; + const text = readDataProperty(value, "text"); + const contentType = readDataProperty(value, "contentType"); + if (typeof text !== "string" || typeof contentType !== "string") return undefined; + + const mime = getMimeType(contentType); + const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; + try { + return new DOMParser().parseFromString(text, parseType); + } catch { + return undefined; + } }; const getMimeType = (contentType: string) => { diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts index 4f341cd02..4a77badf9 100644 --- a/src/app/service/content/scripting.test.ts +++ b/src/app/service/content/scripting.test.ts @@ -3,7 +3,7 @@ import type { MessageSend } from "@Packages/message/types"; import type { TClientPageLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import type { Server } from "@Packages/message/server"; import { RuntimeClient } from "../service_worker/client"; -import ScriptingRuntime from "./scripting"; +import ScriptingRuntime, { serializeDocumentResponse } from "./scripting"; const makeSender = () => ({ sendMessage: vi.fn().mockResolvedValue({ code: 0, data: undefined }), @@ -42,7 +42,6 @@ describe("ScriptingRuntime page bootstrap", () => { {} as Server, senderToExt as unknown as MessageSend, senderToContent as any, - senderToInject as any, senderToInject as any ); @@ -72,4 +71,12 @@ describe("ScriptingRuntime page bootstrap", () => { ); expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); }); + + it("serializes CAT_fetchDocument responses instead of returning a live document reference", () => { + const document = new DOMParser().parseFromString("
ok
", "text/html"); + expect(serializeDocumentResponse(document, "text/html")).toEqual({ + text: expect.stringContaining("
ok
"), + contentType: "text/html", + }); + }); }); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index dc0bc2e27..34e118737 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -2,6 +2,7 @@ import { Client, sendMessage } from "@Packages/message/client"; import { type CustomEventMessage } from "@Packages/message/custom_event_message"; import { forwardMessage, type Server } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; +import type { SerializedDocumentResponse } from "./gm_api/gm_xhr"; import { RuntimeClient } from "../service_worker/client"; import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; @@ -18,6 +19,18 @@ const PageOrContent = { type PageOrContent = ValueOf; +export const serializeDocumentResponse = ( + response: Document | null, + contentType: string +): SerializedDocumentResponse | undefined => { + if (!response) return undefined; + try { + return { text: new XMLSerializer().serializeToString(response), contentType }; + } catch { + return undefined; + } +}; + // For Firefox, StorageArea.setAccessLevel is not implemented. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1724754 // const deliveryStorage = isFirefox() ? chrome.storage.local : chrome.storage.session; @@ -39,9 +52,7 @@ export default class ScriptingRuntime { // 发送给 content的消息接口 private readonly senderToContent: CustomEventMessage, // 发送给inject的消息接口 - private readonly senderToInject: MessageSend, - // 仅用于同步 DOM 节点引用;异步脚本 RPC 使用 senderToInject 的结构化消息。 - private readonly domSenderToInject: CustomEventMessage + private readonly senderToInject: MessageSend ) {} // 广播消息给 content 和 inject @@ -115,18 +126,19 @@ export default class ScriptingRuntime { return false; // 继续转发到 SW } case "CAT_fetchDocument": { - const [url, isContent] = data.params; - // 根据来源选择不同的消息桥(content / inject) - let msg: CustomEventMessage | null = isContent ? this.senderToContent : this.domSenderToInject; return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; - xhr.open("GET", url); - xhr.onloadend = function () { - const nodeId = msg!.sendRelatedTarget(this.response); - resolve(nodeId); - msg = null; + xhr.open("GET", data.params[0]); + xhr.onloadend = () => { + resolve( + serializeDocumentResponse( + xhr.response as Document | null, + xhr.getResponseHeader("Content-Type") || "" + ) + ); }; + xhr.onerror = () => resolve(undefined); xhr.send(); }); } diff --git a/src/scripting.ts b/src/scripting.ts index 2f4a88ce1..0d14a8eaa 100644 --- a/src/scripting.ts +++ b/src/scripting.ts @@ -26,7 +26,6 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { const contentMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.content); const injectMsg = new PageMessage(eventFlag, "scripting"); - const domInjectMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.inject); const server = new Server("scripting", [contentMsg, injectMsg]); @@ -35,7 +34,7 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { const extServer = new Server("scripting", extMsgComm, false); // scriptExecutor的消息接口 // 初始化运行环境 - const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg, domInjectMsg); + const runtime = new ScriptingRuntime(extServer, server, extMsgComm, contentMsg, injectMsg); runtime.init(); // 页面加载,注入脚本 runtime.pageLoad(); From 284b968626e60c3ce7f1c24aa4a864b69e932033 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:33:22 +0900 Subject: [PATCH 083/106] =?UTF-8?q?=F0=9F=94=92=20bind=20early-start=20wra?= =?UTF-8?q?ppers=20to=20document=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 22 ++++++++++++++++++- src/app/service/content/script_executor.ts | 22 ++++++++++++++++++- src/app/service/content/utils.test.ts | 6 +++++ src/app/service/content/utils.ts | 15 ++++++++----- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index fc74a2277..a69b59c5b 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -4,7 +4,7 @@ import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; import type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; -import { compilePreInjectScript, preInjectScriptInfoKey } from "./utils"; +import { compilePreInjectScript, preInjectScriptDocumentUrlKey, preInjectScriptInfoKey } from "./utils"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageDispatchEvent } from "@Packages/message/common"; @@ -174,6 +174,7 @@ describe("ScriptExecutor", () => { pageWindow[script.flag] = genuine; Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); executor.execEarlyScript(script.flag, initEnvInfo); expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); } finally { @@ -273,12 +274,31 @@ describe("ScriptExecutor", () => { } }); + it("rejects an early-start wrapper mounted for a different document URL", () => { + const script = makeScript({ uuid: "executor-early-document-uuid", flag: "#-executor-early-document-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: `${window.location.href}#stale` }); + + try { + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + } + }); + it("accepts the immutable early manifest through the wrapper name fallback", () => { const script = makeScript({ uuid: "executor-early-name-uuid", flag: "#-executor-early-name-uuid" }); const executor = new ScriptExecutor({} as Message, {} as Message); const genuine = vi.fn(); const pageWindow = window as unknown as Record; Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); Object.defineProperty(genuine, "name", { configurable: false, value: JSON.stringify(script) }); try { diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 3fc61df2c..513340ff1 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -3,7 +3,13 @@ import { getStorageName } from "@App/pkg/utils/utils"; import type { EmitEventRequest } from "../service_worker/types"; import ExecScript from "./exec_script"; import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; -import { addStyleSheet, definePropertyListener, preInjectScriptInfoKey, waitBody } from "./utils"; +import { + addStyleSheet, + definePropertyListener, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, + waitBody, +} from "./utils"; import type { TScriptInfo } from "@App/app/repo/scripts"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common"; @@ -142,6 +148,20 @@ export class ScriptExecutor { ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) : undefined; if (scriptInfoDescriptor?.configurable || scriptInfoDescriptor?.writable) return; + // 隔离环境可能在页面导航后才取回预注入函数,必须拒绝挂载于旧 URL 的函数。 + const documentUrlDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentUrlKey) + : undefined; + if ( + !documentUrlDescriptor || + documentUrlDescriptor.configurable || + documentUrlDescriptor.writable || + typeof documentUrlDescriptor.value !== "string" || + documentUrlDescriptor.value !== window.location.href + ) { + return; + } const scriptInfoJSON = typeof scriptInfoDescriptor?.value === "string" ? scriptInfoDescriptor.value diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index cf1dd2d53..774ff747c 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -8,6 +8,7 @@ import { isScriptletUnwrap, addStyle, addStyleSheet, + preInjectScriptDocumentUrlKey, preInjectScriptInfoKey, trimScriptInfo, } from "./utils"; @@ -788,6 +789,11 @@ describe("utils", () => { writable: false, value: expect.any(String), }); + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptDocumentUrlKey)).toMatchObject({ + configurable: false, + writable: false, + value: window.location.href, + }); const context = {}; const named = { value: 42 }; expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 622911dcc..625718c6f 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -18,6 +18,7 @@ const cloneTransportValue = (value: any) => { const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; export const preInjectScriptInfoKey = `${lnStrIntegrity}:scriptInfo`; +export const preInjectScriptDocumentUrlKey = `${lnStrIntegrity}:documentUrl`; export type CompileScriptCodeResource = { name: string; @@ -172,18 +173,22 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } -const codeFunction = (code: string, scriptInfoJSON?: string) => { +const codeFunction = (code: string, scriptInfoJSON?: string, documentUrlExpression?: string) => { // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 const infoProperty = scriptInfoJSON === undefined ? "" - : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });`; + : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });${ + documentUrlExpression === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptDocumentUrlKey}', { value: ${documentUrlExpression} });` + }`; return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; // 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 -const mountCodeFunction = (flag: string, code: string, scriptInfoJSON?: string) => - `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON)})`; +const mountCodeFunction = (flag: string, code: string, scriptInfoJSON?: string, documentUrlExpression?: string) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON, documentUrlExpression)})`; const ZFunction = Function; @@ -321,7 +326,7 @@ export function compilePreInjectScript( f = () => { if (!(${urlCondition})) return false; if (!mounted) { - ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON)}; + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON, "location.href")}; mounted = true; } const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, From 3693b5894d4766d09ea7f39eb9eeebbe9806e9c9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:35:12 +0900 Subject: [PATCH 084/106] =?UTF-8?q?=F0=9F=94=92=20reject=20page=20RPC=20re?= =?UTF-8?q?play=20window=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 10 ++++++---- src/app/service/content/page_rpc.ts | 10 ++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 2d7dee361..81555b7a4 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -363,20 +363,22 @@ describe("page GM RPC", () => { } }); - it("bounds the replay window for each execution binding", () => { + it("fails closed when the replay window reaches its bound", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); - for (let index = 0; index <= 4096; index += 1) { + for (let index = 0; index < 4096; index += 1) { validatePageGMRequest( { version: 1, requestId: `request-${index}`, handle, api: "GM_getValue", params: [] }, registry ); } - // The oldest ID leaves the bounded replay window once newer requests arrive. + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-4096", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("replay window is exhausted"); expect(() => validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) - ).not.toThrow(); + ).toThrow("already used"); }); }); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index 813b84277..d4533bee9 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -371,14 +371,12 @@ export class PageRpcRegistry { } consumeRequestId(binding: PageExecutionBinding, requestId: string): void { - // requestId 只在每个绑定内去重,并保留有限窗口,避免页面长期占用内存。 + // requestId 在每个绑定内只接受一次;达到上限后拒绝新请求,不能遗忘旧 ID 让请求重放。 if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); - binding.requestIds.add(requestId); - while (binding.requestIds.size > MAX_REQUEST_IDS_PER_BINDING) { - const oldest = binding.requestIds.values().next().value as string | undefined; - if (oldest === undefined) break; - binding.requestIds.delete(oldest); + if (binding.requestIds.size >= MAX_REQUEST_IDS_PER_BINDING) { + throw new PageRpcError("page RPC requestId replay window is exhausted"); } + binding.requestIds.add(requestId); } } From 9cf16de49ce1ea4153c99a7a3eccd7458e410770 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:45:31 +0900 Subject: [PATCH 085/106] =?UTF-8?q?=F0=9F=94=92=20close=20service=20worker?= =?UTF-8?q?=20page=20RPC=20replay=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/gm_api/gm_api.test.ts | 80 +++++++++++++++++++ .../service/service_worker/gm_api/gm_api.ts | 8 +- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 094a9f46d..df0ec6b7d 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -331,6 +331,86 @@ describe("page execution binding gate", () => { await expect(api.handlerRequest(request, sender)).resolves.toBe(true); await expect(api.handlerRequest(request, sender)).rejects.toThrow("page RPC requestId was already used"); }); + + it("keeps the page RPC replay window closed after the request-id cap", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + Object.defineProperty(api, "parseRequest", { + configurable: true, + value: vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }), + }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestIds: new Set(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + for (let index = 0; index < 4096; index += 1) { + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: `request-${index}`, + version: 1, + }, + sender + ) + ).resolves.toBe(true); + } + + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-4096", + version: 1, + }, + sender + ) + ).rejects.toThrow("page RPC requestId replay window is exhausted"); + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-0", + version: 1, + }, + sender + ) + ).rejects.toThrow("page RPC requestId was already used"); + }); }); describe("window.focus", () => { diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 28a051041..e82bcec60 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -406,12 +406,10 @@ export default class GMApi { if (binding.requestIds.has(data.requestId)) { throw new Error("page RPC requestId was already used"); } - binding.requestIds.add(data.requestId); - while (binding.requestIds.size > MAX_PAGE_RPC_REQUEST_IDS) { - const oldest = binding.requestIds.values().next().value as string | undefined; - if (oldest === undefined) break; - binding.requestIds.delete(oldest); + if (binding.requestIds.size >= MAX_PAGE_RPC_REQUEST_IDS) { + throw new Error("page RPC requestId replay window is exhausted"); } + binding.requestIds.add(data.requestId); if (data.envTag !== undefined && data.envTag !== binding.envTag) { throw new Error("page execution binding is invalid"); } From 9dd323e15b64e4c677326289ebc32eb501be422c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:47:20 +0900 Subject: [PATCH 086/106] =?UTF-8?q?=F0=9F=94=92=20validate=20page=20RPC=20?= =?UTF-8?q?identity=20before=20replay=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/gm_api/gm_api.test.ts | 9 +++++++-- src/app/service/service_worker/gm_api/gm_api.ts | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index df0ec6b7d..6150e2be3 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -327,9 +327,14 @@ describe("page execution binding gate", () => { executionHandle: "handle-a", requestId: "request-a", version: 1 as const, + envTag: "ct" as const, }; - await expect(api.handlerRequest(request, sender)).resolves.toBe(true); - await expect(api.handlerRequest(request, sender)).rejects.toThrow("page RPC requestId was already used"); + await expect(api.handlerRequest(request, sender)).rejects.toThrow("page execution binding is invalid"); + expect(binding.requestIds).toHaveLength(0); + + const validRequest = { ...request, envTag: "it" as const }; + await expect(api.handlerRequest(validRequest, sender)).resolves.toBe(true); + await expect(api.handlerRequest(validRequest, sender)).rejects.toThrow("page RPC requestId was already used"); }); it("keeps the page RPC replay window closed after the request-id cap", async () => { diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index e82bcec60..97920e902 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -400,6 +400,9 @@ export default class GMApi { if (!binding.allowedAPIs.has(data.api)) { throw new Error("API is not granted to this execution"); } + if (data.envTag !== undefined && data.envTag !== binding.envTag) { + throw new Error("page execution binding is invalid"); + } if (typeof data.requestId !== "string" || !data.requestId || data.requestId.length > 256) { throw new Error("page RPC requestId is invalid"); } @@ -410,9 +413,6 @@ export default class GMApi { throw new Error("page RPC requestId replay window is exhausted"); } binding.requestIds.add(data.requestId); - if (data.envTag !== undefined && data.envTag !== binding.envTag) { - throw new Error("page execution binding is invalid"); - } data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; } const api = PermissionVerifyApiGet(data.api); From 2acb238c11e3f6f6ea8f13b7facd461afd07dfc3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:57:32 +0900 Subject: [PATCH 087/106] =?UTF-8?q?=F0=9F=94=92=20harden=20GM=20value=20re?= =?UTF-8?q?sult=20records?= 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 | 24 +++++++++++++++++++ src/app/service/content/gm_api/gm_api.ts | 18 +++++++------- 2 files changed, 33 insertions(+), 9 deletions(-) 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 d9b748321..47fc63e33 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -769,6 +769,30 @@ describe.concurrent("GM_value", () => { expect(api.GM_getValue(api, "leaked")).toBeUndefined(); }); + it("returns __proto__ as an own key without changing the result prototype", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue", "GM_getValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const stored = { leaked: "secret" }; + + api.GM_setValue(api, "__proto__", stored); + + const selected = api.GM_getValues(api, ["__proto__"]); + const defaults = Object.create(null) as Record; + defaults.__proto__ = "fallback"; + const withDefaults = api.GM_getValues(api, defaults); + + expect(Object.getPrototypeOf(selected)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(selected, "__proto__")).toBe(true); + expect(selected.__proto__).toEqual(stored); + expect(Object.getPrototypeOf(withDefaults)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(withDefaults, "__proto__")).toBe(true); + expect(withDefaults.__proto__).toEqual(stored); + }); + it.concurrent("GM_setValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue"]; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index b81f363ca..2e517987a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -459,7 +459,7 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_listValues(ctx: GMApi): string[] { if (!ctx.scriptRes) return []; - const keys = Object.keys(ctx.scriptRes.value); + const keys = Native.objectKeys(ctx.scriptRes.value); return keys; } @@ -468,7 +468,7 @@ export default class GMApi extends GM_Base { // Asynchronous wrapper for GM_listValues to support GM.listValues return new Promise((resolve) => { if (!ctx.scriptRes) return resolve([]); - const keys = Object.keys(ctx.scriptRes.value); + const keys = Native.objectKeys(ctx.scriptRes.value); resolve(keys); }); } @@ -488,8 +488,8 @@ export default class GMApi extends GM_Base { // Returns all values return customClone(ctx.scriptRes.value)!; } - const result: TGMKeyValue = {}; - if (Array.isArray(keysOrDefaults)) { + const result: TGMKeyValue = Native.objectCreate(null); + if (Native.arrayIsArray(keysOrDefaults)) { // 键名数组 // Handle array of keys (e.g., ['foo', 'bar']) for (let index = 0; index < keysOrDefaults.length; index++) { @@ -500,15 +500,15 @@ export default class GMApi extends GM_Base { if (value && typeof value === "object") { value = customClone(value)!; } - result[key] = value; + setOwnValue(result, key, value); } } } else { // 对象 键: 默认值 // Handle object with default values (e.g., { foo: 1, bar: 2, baz: 3 }) - for (const key of Object.keys(keysOrDefaults)) { + for (const key of Native.objectKeys(keysOrDefaults)) { const defaultValue = keysOrDefaults[key]; - result[key] = _GM_getValue(ctx, key, defaultValue); + setOwnValue(result, key, _GM_getValue(ctx, key, defaultValue)); } } return result; @@ -538,7 +538,7 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_deleteValues(ctx: GMApi, keys: string[]) { if (!ctx.scriptRes) return; - if (!Array.isArray(keys)) { + if (!Native.arrayIsArray(keys)) { console.warn("GM_deleteValues: keys must be string[]"); return; } @@ -554,7 +554,7 @@ export default class GMApi extends GM_Base { public "GM.deleteValues"(ctx: GMApi, keys: string[]): Promise { if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { - if (!Array.isArray(keys)) { + if (!Native.arrayIsArray(keys)) { throw new Error("GM.deleteValues: keys must be string[]"); } else { const req = {} as Record; From 39b695dc2ec54954ce03022ca1a1145aa63cf481 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:04:21 +0900 Subject: [PATCH 088/106] =?UTF-8?q?=F0=9F=94=92=20restrict=20script=20atta?= =?UTF-8?q?chment=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/repo/agent_chat.test.ts | 38 +++++++++++++++++++ src/app/repo/agent_chat.ts | 14 +++++++ .../service/agent/service_worker/opfs.test.ts | 18 +++++++++ .../agent/service_worker/opfs_service.ts | 3 ++ .../agent/service_worker/test-helpers.ts | 1 + 5 files changed, 74 insertions(+) diff --git a/src/app/repo/agent_chat.test.ts b/src/app/repo/agent_chat.test.ts index 95b0dc1ce..a3fc1da32 100644 --- a/src/app/repo/agent_chat.test.ts +++ b/src/app/repo/agent_chat.test.ts @@ -157,6 +157,44 @@ describe("AgentChatRepo 附件存储", () => { expect(result).toBeInstanceOf(Blob); }); + it("附件读取权限只授予拥有引用该附件的脚本会话", async () => { + const conversation = await repo.createConversation({ + id: "conv-script-attachment", + ownerScriptUuid: "script-a", + title: "Script", + modelId: "m1", + createtime: 1, + updatetime: 1, + }); + await repo.saveMessages( + conversation.id, + [ + { + id: "message-script-attachment", + conversationId: conversation.id, + role: "user", + content: [{ type: "image", attachmentId: "script-image", mimeType: "image/png" }], + ownedAttachmentIds: ["script-image"], + createtime: 1, + }, + { + id: "message-borrowed-attachment", + conversationId: conversation.id, + role: "user", + content: [{ type: "image", attachmentId: "borrowed-image", mimeType: "image/png" }], + createtime: 2, + }, + ], + undefined, + { generation: conversation.generation! } + ); + + await expect(repo.isAttachmentAccessibleToScript("script-image", "script-a")).resolves.toBe(true); + await expect(repo.isAttachmentAccessibleToScript("borrowed-image", "script-a")).resolves.toBe(false); + await expect(repo.isAttachmentAccessibleToScript("script-image", "script-b")).resolves.toBe(false); + await expect(repo.isAttachmentAccessibleToScript("unreferenced", "script-a")).resolves.toBe(false); + }); + it("getAttachment 不存在的附件应返回 null", async () => { const result = await repo.getAttachment("nonexistent"); diff --git a/src/app/repo/agent_chat.ts b/src/app/repo/agent_chat.ts index 88fbaf1ff..5643f89da 100644 --- a/src/app/repo/agent_chat.ts +++ b/src/app/repo/agent_chat.ts @@ -500,6 +500,20 @@ export class AgentChatRepo extends OPFSRepo { } } + // 用户脚本只能读取自己拥有的会话消息声明过的附件;附件文件本身不携带 owner 元数据, + // 因此必须以持久化消息中的所有权字段作为授权依据,不能仅凭可猜测的附件 ID 或借用引用放行。 + async isAttachmentAccessibleToScript(id: string, scriptUuid: string): Promise { + if (!id || !scriptUuid) return false; + for (const conversation of await this.listConversations()) { + if (conversation.ownerScriptUuid !== scriptUuid) continue; + const snapshot = await this.getMessageSnapshot(conversation.id, conversation.generation); + if (collectMessageAttachmentIds(snapshot.messages, isLegacyGeneration(conversation.generation)).has(id)) { + return true; + } + } + return false; + } + // 删除单个附件(同时清理新旧路径) async deleteAttachment(id: string): Promise { // 新路径: agents/workspace/uploads/{id} diff --git a/src/app/service/agent/service_worker/opfs.test.ts b/src/app/service/agent/service_worker/opfs.test.ts index 9f2377f0b..e48a94a29 100644 --- a/src/app/service/agent/service_worker/opfs.test.ts +++ b/src/app/service/agent/service_worker/opfs.test.ts @@ -176,6 +176,24 @@ describe("handleOPFSApi", () => { expect(mockRepo.getAttachment).toHaveBeenCalledWith("att-123"); }); + it("readAttachment 不得读取其他脚本未拥有的附件", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.isAttachmentAccessibleToScript.mockResolvedValue(false); + mockRepo.getAttachment = vi.fn().mockResolvedValue(new Blob(["secret"], { type: "image/png" })); + + await expect( + service.handleOPFSApi( + { + action: "readAttachment", + id: "att-private", + scriptUuid: "script-b", + }, + mockOPFSSender + ) + ).rejects.toThrow("Attachment access denied: att-private"); + expect(mockRepo.getAttachment).not.toHaveBeenCalled(); + }); + it("readAttachment 附件不存在时应抛出错误", async () => { const { service, mockRepo } = createTestService(); mockRepo.getAttachment = vi.fn().mockResolvedValue(null); diff --git a/src/app/service/agent/service_worker/opfs_service.ts b/src/app/service/agent/service_worker/opfs_service.ts index ac3a51d30..c082e5331 100644 --- a/src/app/service/agent/service_worker/opfs_service.ts +++ b/src/app/service/agent/service_worker/opfs_service.ts @@ -59,6 +59,9 @@ export class AgentOPFSService { return { path: safePath2, content: textContent, size: file2.size }; } case "readAttachment": { + if (!(await repo.isAttachmentAccessibleToScript(request.id, request.scriptUuid))) { + throw new Error(`Attachment access denied: ${request.id}`); + } const blob = await repo.getAttachment(request.id); if (!blob) { throw new Error(`Attachment not found: ${request.id}`); diff --git a/src/app/service/agent/service_worker/test-helpers.ts b/src/app/service/agent/service_worker/test-helpers.ts index 3944c5e4a..38422e813 100644 --- a/src/app/service/agent/service_worker/test-helpers.ts +++ b/src/app/service/agent/service_worker/test-helpers.ts @@ -90,6 +90,7 @@ export function createTestService() { getTasks: vi.fn().mockResolvedValue([]), getTaskSnapshot: vi.fn().mockResolvedValue({ generation: "test-generation", revision: 0, tasks: [] }), saveTasks: vi.fn().mockResolvedValue(undefined), + isAttachmentAccessibleToScript: vi.fn().mockResolvedValue(true), getAttachment: vi.fn().mockResolvedValue(null), saveAttachment: vi.fn().mockResolvedValue(0), deleteAttachment: vi.fn().mockResolvedValue(undefined), From c46dfe1c7e9120aad5aa3aa74b16ba41c270e6d6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:19:28 +0900 Subject: [PATCH 089/106] =?UTF-8?q?=F0=9F=94=92=20keep=20MAIN=20script=20p?= =?UTF-8?q?ayload=20off=20page=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/main_world_page_load_gate.test.ts | 14 +++- .../content/main_world_page_load_gate.ts | 4 +- src/app/service/content/scripting.test.ts | 73 ++++++++++++------- src/app/service/content/scripting.ts | 19 +++-- src/inject.ts | 14 +++- 5 files changed, 87 insertions(+), 37 deletions(-) diff --git a/src/app/service/content/main_world_page_load_gate.test.ts b/src/app/service/content/main_world_page_load_gate.test.ts index 9a273a4ed..53a948ae2 100644 --- a/src/app/service/content/main_world_page_load_gate.test.ts +++ b/src/app/service/content/main_world_page_load_gate.test.ts @@ -29,7 +29,8 @@ describe("createMainWorldPageLoadGate", () => { it("releases one queued payload only when native transport is unavailable", async () => { const receivePageLoad = vi.fn(); - const gate = createMainWorldPageLoadGate(async () => false, receivePageLoad); + const requestFallbackPageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => false, receivePageLoad, requestFallbackPageLoad); const first = { source: "page" }; const second = { source: "page-after-fallback" }; @@ -38,12 +39,23 @@ describe("createMainWorldPageLoadGate", () => { await Promise.resolve(); expect(receivePageLoad).toHaveBeenCalledWith(first); + expect(requestFallbackPageLoad).toHaveBeenCalledOnce(); gate.onPageLoad(second); expect(receivePageLoad).toHaveBeenLastCalledWith(second); expect(receivePageLoad).toHaveBeenCalledTimes(2); }); + it("does not request a page-visible fallback after native transport succeeds", async () => { + const requestFallbackPageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => true, vi.fn(), requestFallbackPageLoad); + + gate.onBootstrap("bootstrap-token"); + await Promise.resolve(); + + expect(requestFallbackPageLoad).not.toHaveBeenCalled(); + }); + it("does not reopen or fall back after the native channel has been selected", async () => { const receivePageLoad = vi.fn(); const openNativeChannel = vi.fn(async () => true); diff --git a/src/app/service/content/main_world_page_load_gate.ts b/src/app/service/content/main_world_page_load_gate.ts index 9d937cc70..35652ab7e 100644 --- a/src/app/service/content/main_world_page_load_gate.ts +++ b/src/app/service/content/main_world_page_load_gate.ts @@ -7,7 +7,8 @@ export type MainWorldPageLoadGate = { export const createMainWorldPageLoadGate = ( openNativeChannel: (bootstrapToken: string) => Promise, - receivePageLoad: (data: unknown) => void + receivePageLoad: (data: unknown) => void, + requestFallbackPageLoad: () => void = () => undefined ): MainWorldPageLoadGate => { let state: MainWorldPageLoadGateState = "waiting"; let pendingPageLoad: unknown; @@ -16,6 +17,7 @@ export const createMainWorldPageLoadGate = ( const finishOpening = (connected: boolean): void => { if (state !== "opening") return; state = connected ? "native" : "fallback"; + if (state === "fallback") requestFallbackPageLoad(); if (state === "fallback" && hasPendingPageLoad) { receivePageLoad(pendingPageLoad); } diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts index 4a77badf9..2b75f8fe1 100644 --- a/src/app/service/content/scripting.test.ts +++ b/src/app/service/content/scripting.test.ts @@ -37,39 +37,60 @@ describe("ScriptingRuntime page bootstrap", () => { const senderToExt = makeSender(); const senderToContent = makeSender(); const senderToInject = makeSender(); + const handlers = new Map unknown>(); + const server = { + on: vi.fn((action: string, handler: (data: unknown) => unknown) => handlers.set(action, handler)), + }; + const extServer = { on: vi.fn() }; + const storageLocal = chrome.storage.local as unknown as { + onChanged?: { addListener: (listener: (changes: unknown) => void) => void }; + }; + const originalOnChanged = storageLocal.onChanged; + storageLocal.onChanged = { addListener: vi.fn() }; const runtime = new ScriptingRuntime( - {} as Server, - {} as Server, + extServer as unknown as Server, + server as unknown as Server, senderToExt as unknown as MessageSend, senderToContent as any, senderToInject as any ); - runtime.pageLoad(); - await Promise.resolve(); - await Promise.resolve(); + try { + runtime.init(); + runtime.pageLoad(); + await Promise.resolve(); + await Promise.resolve(); - expect(pageLoad).toHaveBeenCalledWith("it"); - expect(senderToContent.sendMessage).toHaveBeenCalledWith( - expect.objectContaining({ - action: "content/pageLoad", - data: expect.objectContaining({ - bootstrapToken: "bootstrap-token", - extensionOrigin: { - protocol: "chrome-extension:", - hostname: chrome.runtime.id, - port: "", - }, - }), - }) - ); - expect(senderToInject.sendMessage).toHaveBeenCalledWith( - expect.objectContaining({ - action: "inject/bootstrap", - data: { bootstrapToken: "inject-bootstrap-token" }, - }) - ); - expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); + expect(pageLoad).toHaveBeenCalledWith("it"); + expect(senderToContent.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ + bootstrapToken: "bootstrap-token", + extensionOrigin: { + protocol: "chrome-extension:", + hostname: chrome.runtime.id, + port: "", + }, + }), + }) + ); + expect(senderToInject.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/bootstrap", + data: { bootstrapToken: "inject-bootstrap-token" }, + }) + ); + expect(senderToInject.sendMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "inject/pageLoad" }) + ); + + handlers.get("pageLoadFallback")?.({}); + await Promise.resolve(); + expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); + } finally { + storageLocal.onChanged = originalOnChanged; + } }); it("serializes CAT_fetchDocument responses instead of returning a live document reference", () => { diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 34e118737..4baddadb8 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -2,12 +2,13 @@ import { Client, sendMessage } from "@Packages/message/client"; import { type CustomEventMessage } from "@Packages/message/custom_event_message"; import { forwardMessage, type Server } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; +import type { TScriptInfo } from "@App/app/repo/scripts"; import type { SerializedDocumentResponse } from "./gm_api/gm_xhr"; import { RuntimeClient } from "../service_worker/client"; import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; -import type { ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types"; import { getExtensionOrigin, getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; import { uuidv4 } from "@App/pkg/utils/uuid"; @@ -40,6 +41,8 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 export default class ScriptingRuntime { // 只记录当前页面仍有脚本使用的 storageName,storage 广播不应唤醒无关脚本。 private activeStorageNames = new Map(); + // MAIN world 的完整脚本资料只在原生通道失败时才走页面桥;原生成功时由 service worker 直接投递。 + private fallbackInjectPageLoad?: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }; // 页面请求必须先在此注册句柄,再由 transform 解析为隔离 broker 可接受的身份。 private readonly pageRpc = new PageRpcRegistry(); constructor( @@ -76,6 +79,12 @@ export default class ScriptingRuntime { // USER_SCRIPT 的私有值更新通过原生扩展端口投递。 return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); }); + this.server.on("pageLoadFallback", () => { + const pageLoad = this.fallbackInjectPageLoad; + if (!pageLoad) return undefined; + this.fallbackInjectPageLoad = undefined; + return new Client(this.senderToInject, "inject").do("pageLoad", pageLoad); + }); this.server.on("logger", (data: Logger) => { LoggerCore.logger().log(data.level, data.message, data.label); }); @@ -223,16 +232,10 @@ export default class ScriptingRuntime { } if (typeof userScriptInjectBootstrapToken === "string" && userScriptInjectBootstrapToken.length > 0) { + this.fallbackInjectPageLoad = { scripts: preparedInjectScriptList, envInfo }; const injectClient = new Client(this.senderToInject, "inject"); injectClient.do("bootstrap", { bootstrapToken: userScriptInjectBootstrapToken }); } - - // 向页面 发送脚本列表及环境信息 - if (preparedInjectScriptList.length) { - const injectClient = new Client(this.senderToInject, "inject"); - // 根据@inject-into content过滤脚本 - injectClient.do("pageLoad", { scripts: preparedInjectScriptList, envInfo }); - } }); } } diff --git a/src/inject.ts b/src/inject.ts index 44619cc9c..ff8049211 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -4,6 +4,7 @@ import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { PageMessage } from "@Packages/message/page_message"; import { ExtensionMessage, hasNativeRuntimeChannel } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; +import { Client } from "@Packages/message/client"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; import { getEventFlag } from "@Packages/message/common"; @@ -91,13 +92,24 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde }; if (pageServer) { - const pageLoadGate = createMainWorldPageLoadGate(openNativeChannel, (data) => runtime.receivePageLoad(data)); + const pageLoadGate = createMainWorldPageLoadGate( + openNativeChannel, + (data) => runtime.receivePageLoad(data), + () => { + void new Client(pageMsg, "scripting").do("pageLoadFallback"); + } + ); pageServer.on("bootstrap", (data: { bootstrapToken?: unknown }) => { if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; reconnectToken = data.bootstrapToken; pageLoadGate.onBootstrap(data.bootstrapToken); }); pageServer.on("pageLoad", pageLoadGate.onPageLoad); + } else { + // 没有原生 runtime 通道时,bootstrap 只作为页面桥上的兼容握手,随后请求完整 pageLoad。 + server.on("bootstrap", () => { + void new Client(pageMsg, "scripting").do("pageLoadFallback"); + }); } runtime.init(); From deea129f8467a1c2d47a6c619566e63f40d76fc3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:00:37 +0900 Subject: [PATCH 090/106] =?UTF-8?q?=F0=9F=94=92=20isolate=20service=20work?= =?UTF-8?q?er=20value=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/value.test.ts | 24 ++++++++++++++++++++ src/app/service/service_worker/value.ts | 15 ++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index c42ff5a2e..a73e0a486 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -121,6 +121,30 @@ describe("ValueService - setValue 方法测试", () => { expect((savedData as Record).leaked).toBeUndefined(); }); + it("does not let a bound config key change the returned value object's prototype", async () => { + const mockScript = createMockScript({ + config: { + settings: { + setting: { + bind: "$__proto__", + default: { polluted: true }, + index: 0, + }, + }, + } as any, + }); + const stored = {}; + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue({ data: stored } as any); + + const values = await valueService.getScriptValue(mockScript); + + expect(Object.getPrototypeOf(values)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(values, "__proto__")).toBe(true); + expect(values.__proto__).toBeUndefined(); + expect((values as Record).polluted).toBeUndefined(); + }); + it("应该成功设置新脚本的值", async () => { // 准备测试数据 const mockScript = createMockScript(); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 5855cc43a..fd741d9c8 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -50,10 +50,12 @@ export class ValueService { } async getScriptValueDetails(script: Script) { - let data: { [key: string]: any } = {}; + const data: { [key: string]: any } = Object.create(null); const ret = await this.valueDAO.get(getStorageName(script)); if (ret) { - data = ret.data; + for (const key of Object.keys(ret.data)) { + setOwnValue(data, key, ret.data[key]); + } } const newValues = data; // 和userconfig组装 @@ -71,10 +73,13 @@ export class ValueService { // 动态变量 if (tab[key].bind) { const bindKey = tab[key].bind!.substring(1); - newValues[bindKey] = data[bindKey] === undefined ? undefined : data[bindKey]; + setOwnValue(newValues, bindKey, data[bindKey] === undefined ? undefined : data[bindKey]); } - newValues[`${tabKey}.${key}`] = - data[`${tabKey}.${key}`] === undefined ? tab[key].default : data[`${tabKey}.${key}`]; + setOwnValue( + newValues, + `${tabKey}.${key}`, + data[`${tabKey}.${key}`] === undefined ? tab[key].default : data[`${tabKey}.${key}`] + ); } } } From 71022c7155aae9ec478fbd502da49b873a70527a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:17:17 +0900 Subject: [PATCH 091/106] =?UTF-8?q?=F0=9F=94=92=20serialize=20CDP=20monito?= =?UTF-8?q?r=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/service_worker/dom_cdp.test.ts | 13 ++++++++ .../service/agent/service_worker/dom_cdp.ts | 33 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/app/service/agent/service_worker/dom_cdp.test.ts b/src/app/service/agent/service_worker/dom_cdp.test.ts index 3a84003b5..c7f97ddec 100644 --- a/src/app/service/agent/service_worker/dom_cdp.test.ts +++ b/src/app/service/agent/service_worker/dom_cdp.test.ts @@ -103,6 +103,19 @@ describe("agent_dom_cdp", () => { await cdpStopMonitor(999, "script-a"); }); + it("并发重启同一标签页的监控不会泄漏旧监听器", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await Promise.all([cdpStartMonitor(997, "script-a"), cdpStartMonitor(997, "script-a")]); + await cdpStopMonitor(997, "script-a"); + + expect(mockAttach).toHaveBeenCalledTimes(2); + expect(mockDetach).toHaveBeenCalledTimes(2); + expect(chrome.debugger.onEvent.addListener as ReturnType).toHaveBeenCalledTimes(2); + expect(chrome.debugger.onEvent.removeListener as ReturnType).toHaveBeenCalledTimes(2); + }); + it("页面监控的结果不能被其他脚本读取或停止", async () => { mockTabsGet.mockResolvedValue({ url: "https://example.com" }); mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); diff --git a/src/app/service/agent/service_worker/dom_cdp.ts b/src/app/service/agent/service_worker/dom_cdp.ts index 0a0334f30..26d08229d 100644 --- a/src/app/service/agent/service_worker/dom_cdp.ts +++ b/src/app/service/agent/service_worker/dom_cdp.ts @@ -23,6 +23,25 @@ type MonitorSession = { }; const activeMonitors = new Map(); +const monitorOperationQueues = new Map>(); + +async function withMonitorOperation(tabId: number, operation: () => Promise): Promise { + const previous = monitorOperationQueues.get(tabId) || Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + monitorOperationQueues.set(tabId, current); + await previous; + try { + return await operation(); + } finally { + release(); + if (monitorOperationQueues.get(tabId) === current) { + monitorOperationQueues.delete(tabId); + } + } +} // 生命周期管理:attach → 执行 → detach // 如果该 tabId 已有活跃的 monitor(已 attach),则复用连接,不做 attach/detach @@ -240,14 +259,18 @@ export async function cdpScreenshot(tabId: number, options?: ScreenshotOptions): // ---- 页面监控(startMonitor / stopMonitor) ---- // 启动页面监控:attach debugger,纯 CDP 事件监听(dialog + DOM 变化),零注入 -export async function cdpStartMonitor(tabId: number, ownerScriptUuid?: string): Promise { +export function cdpStartMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => startMonitor(tabId, ownerScriptUuid)); +} + +async function startMonitor(tabId: number, ownerScriptUuid?: string): Promise { // 如果已有 monitor,先停止 const current = activeMonitors.get(tabId); if (current) { if (current.ownerScriptUuid !== ownerScriptUuid) { throw new Error("Monitor belongs to another script"); } - await cdpStopMonitor(tabId, ownerScriptUuid); + await stopMonitor(tabId, ownerScriptUuid); } const dialogs: Array<{ type: string; message: string }> = []; @@ -323,7 +346,11 @@ function stripHtmlTags(html: string): string { } // 停止监控:纯 CDP 解析新增节点 → 收集结果 → detach -export async function cdpStopMonitor(tabId: number, ownerScriptUuid?: string): Promise { +export function cdpStopMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => stopMonitor(tabId, ownerScriptUuid)); +} + +async function stopMonitor(tabId: number, ownerScriptUuid?: string): Promise { const monitor = activeMonitors.get(tabId); if (monitor && monitor.ownerScriptUuid !== ownerScriptUuid) { throw new Error("Monitor belongs to another script"); From 8528137104d5e091da53771b4833f864740aa825 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:35:17 +0900 Subject: [PATCH 092/106] =?UTF-8?q?=F0=9F=94=92=20allow=20long-lived=20pag?= =?UTF-8?q?e=20RPC=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/page_rpc.test.ts | 6 +++--- src/app/service/content/page_rpc.ts | 6 +----- src/app/service/service_worker/gm_api/gm_api.test.ts | 4 ++-- src/app/service/service_worker/gm_api/gm_api.ts | 5 ----- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 81555b7a4..2aa749caf 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -363,7 +363,7 @@ describe("page GM RPC", () => { } }); - it("fails closed when the replay window reaches its bound", () => { + it("accepts more than 4096 requests while still rejecting replay", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); @@ -374,9 +374,9 @@ describe("page GM RPC", () => { ); } - expect(() => + expect( validatePageGMRequest({ version: 1, requestId: "request-4096", handle, api: "GM_getValue", params: [] }, registry) - ).toThrow("replay window is exhausted"); + ).toMatchObject({ requestId: "request-4096" }); expect(() => validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) ).toThrow("already used"); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts index d4533bee9..f17eec44b 100644 --- a/src/app/service/content/page_rpc.ts +++ b/src/app/service/content/page_rpc.ts @@ -5,7 +5,6 @@ import { Native, nativeReflectApply } from "./global"; export const PAGE_RPC_VERSION = 1 as const; const MAX_REQUEST_ID_LENGTH = 256; -const MAX_REQUEST_IDS_PER_BINDING = 4096; const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; const nativeObjectToString = Object.prototype.toString; const nativeMapForEach = Map.prototype.forEach; @@ -371,11 +370,8 @@ export class PageRpcRegistry { } consumeRequestId(binding: PageExecutionBinding, requestId: string): void { - // requestId 在每个绑定内只接受一次;达到上限后拒绝新请求,不能遗忘旧 ID 让请求重放。 + // requestId 在每个绑定内只接受一次;绑定销毁时一并释放,避免重放而不截断长时间运行的脚本。 if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); - if (binding.requestIds.size >= MAX_REQUEST_IDS_PER_BINDING) { - throw new PageRpcError("page RPC requestId replay window is exhausted"); - } binding.requestIds.add(requestId); } } diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 6150e2be3..000064bc6 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -337,7 +337,7 @@ describe("page execution binding gate", () => { await expect(api.handlerRequest(validRequest, sender)).rejects.toThrow("page RPC requestId was already used"); }); - it("keeps the page RPC replay window closed after the request-id cap", async () => { + it("accepts more than 4096 page RPC requests while still rejecting replay", async () => { const api = Object.create(GMApi.prototype) as GMApi; Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); Object.defineProperty(api, "permissionVerify", { @@ -400,7 +400,7 @@ describe("page execution binding gate", () => { }, sender ) - ).rejects.toThrow("page RPC requestId replay window is exhausted"); + ).resolves.toBe(true); await expect( api.handlerRequest( { diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 97920e902..eb8619c32 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -141,8 +141,6 @@ const cleanupOnAPIError = (requestId: string) => { headersSettled(markerID); // 处理完毕 }; -const MAX_PAGE_RPC_REQUEST_IDS = 4096; - // GMExternalDependencies接口定义 // 为了支持外部依赖注入,方便测试和扩展 interface IGMExternalDependencies { @@ -409,9 +407,6 @@ export default class GMApi { if (binding.requestIds.has(data.requestId)) { throw new Error("page RPC requestId was already used"); } - if (binding.requestIds.size >= MAX_PAGE_RPC_REQUEST_IDS) { - throw new Error("page RPC requestId replay window is exhausted"); - } binding.requestIds.add(data.requestId); data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; } From 96c577c0cdab3fcc4fff04dd91cd58b815a40818 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:35:43 +0900 Subject: [PATCH 093/106] =?UTF-8?q?=F0=9F=94=92=20preserve=20early=20wrapp?= =?UTF-8?q?ers=20across=20same-document=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 29 ++++++++++++++++--- src/app/service/content/script_executor.ts | 24 +++++++++++++-- src/app/service/content/utils.test.ts | 6 ++++ src/app/service/content/utils.ts | 27 +++++++++++++---- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a69b59c5b..65ecfc42a 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -4,7 +4,12 @@ import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; import type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; -import { compilePreInjectScript, preInjectScriptDocumentUrlKey, preInjectScriptInfoKey } from "./utils"; +import { + compilePreInjectScript, + preInjectScriptDocumentIdKey, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, +} from "./utils"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageDispatchEvent } from "@Packages/message/common"; @@ -12,6 +17,16 @@ const styleUrl = "https://example.com/style.css"; const secondStyleUrl = "https://example.com/second-style.css"; const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; +beforeEach(() => { + if (!Object.prototype.hasOwnProperty.call(window, preInjectScriptDocumentIdKey)) { + Object.defineProperty(window, preInjectScriptDocumentIdKey, { + configurable: false, + writable: false, + value: "script-executor-test-document", + }); + } +}); + function makeScript(overrides: Partial> = {}): ScriptLoadInfo { return { uuid: "executor-test-uuid", @@ -175,6 +190,7 @@ describe("ScriptExecutor", () => { pageWindow[script.flag] = genuine; Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); executor.execEarlyScript(script.flag, initEnvInfo); expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); } finally { @@ -274,20 +290,24 @@ describe("ScriptExecutor", () => { } }); - it("rejects an early-start wrapper mounted for a different document URL", () => { + it("accepts an early-start wrapper after a same-document URL change", () => { const script = makeScript({ uuid: "executor-early-document-uuid", flag: "#-executor-early-document-uuid" }); const executor = new ScriptExecutor({} as Message, {} as Message); const genuine = vi.fn(); const pageWindow = window as unknown as Record; + const initialUrl = window.location.href; Object.defineProperty(genuine, fnStrIntegrity, { value: true }); Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); - Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: `${window.location.href}#stale` }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: initialUrl }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); try { + window.history.pushState({}, "", `${initialUrl}#same-document-change`); pageWindow[script.flag] = genuine; executor.execEarlyScript(script.flag, initEnvInfo); - expect(genuine).not.toHaveBeenCalled(); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); } finally { + window.history.replaceState({}, "", initialUrl); delete pageWindow[script.flag]; } }); @@ -299,6 +319,7 @@ describe("ScriptExecutor", () => { const pageWindow = window as unknown as Record; Object.defineProperty(genuine, fnStrIntegrity, { value: true }); Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); Object.defineProperty(genuine, "name", { configurable: false, value: JSON.stringify(script) }); try { diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 513340ff1..377e22a67 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -6,6 +6,7 @@ import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; import { addStyleSheet, definePropertyListener, + preInjectScriptDocumentIdKey, preInjectScriptDocumentUrlKey, preInjectScriptInfoKey, waitBody, @@ -148,7 +149,8 @@ export class ScriptExecutor { ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) : undefined; if (scriptInfoDescriptor?.configurable || scriptInfoDescriptor?.writable) return; - // 隔离环境可能在页面导航后才取回预注入函数,必须拒绝挂载于旧 URL 的函数。 + // The wrapper is installed on this document's window. Same-document history changes must not invalidate it; + // a full navigation creates a new window and cannot retain the old function. const documentUrlDescriptor = typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentUrlKey) @@ -157,8 +159,24 @@ export class ScriptExecutor { !documentUrlDescriptor || documentUrlDescriptor.configurable || documentUrlDescriptor.writable || - typeof documentUrlDescriptor.value !== "string" || - documentUrlDescriptor.value !== window.location.href + typeof documentUrlDescriptor.value !== "string" + ) { + return; + } + const documentIdDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentIdKey) + : undefined; + const currentDocumentIdDescriptor = Native.objectGetOwnPropertyDescriptor(window, preInjectScriptDocumentIdKey); + if ( + !documentIdDescriptor || + documentIdDescriptor.configurable || + documentIdDescriptor.writable || + typeof documentIdDescriptor.value !== "string" || + !currentDocumentIdDescriptor || + currentDocumentIdDescriptor.configurable || + currentDocumentIdDescriptor.writable || + currentDocumentIdDescriptor.value !== documentIdDescriptor.value ) { return; } diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 774ff747c..3cac0cfba 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -8,6 +8,7 @@ import { isScriptletUnwrap, addStyle, addStyleSheet, + preInjectScriptDocumentIdKey, preInjectScriptDocumentUrlKey, preInjectScriptInfoKey, trimScriptInfo, @@ -794,6 +795,11 @@ describe("utils", () => { writable: false, value: window.location.href, }); + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptDocumentIdKey)).toMatchObject({ + configurable: false, + writable: false, + value: expect.any(String), + }); const context = {}; const named = { value: 42 }; expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 625718c6f..7caa009c8 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -19,6 +19,7 @@ const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; const znRand = process.env.SC_ZN_RAND; export const preInjectScriptInfoKey = `${lnStrIntegrity}:scriptInfo`; export const preInjectScriptDocumentUrlKey = `${lnStrIntegrity}:documentUrl`; +export const preInjectScriptDocumentIdKey = `${lnStrIntegrity}:documentId`; export type CompileScriptCodeResource = { name: string; @@ -173,7 +174,12 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } -const codeFunction = (code: string, scriptInfoJSON?: string, documentUrlExpression?: string) => { +const codeFunction = ( + code: string, + scriptInfoJSON?: string, + documentUrlExpression?: string, + documentIdExpression?: string +) => { // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 const infoProperty = scriptInfoJSON === undefined @@ -181,14 +187,24 @@ const codeFunction = (code: string, scriptInfoJSON?: string, documentUrlExpressi : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });${ documentUrlExpression === undefined ? "" - : ` Object.defineProperty(f, '${preInjectScriptDocumentUrlKey}', { value: ${documentUrlExpression} });` + : ` Object.defineProperty(f, '${preInjectScriptDocumentUrlKey}', { value: ${documentUrlExpression} });${ + documentIdExpression === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptDocumentIdKey}', { value: ${documentIdExpression} });` + }` }`; return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; }; // 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 -const mountCodeFunction = (flag: string, code: string, scriptInfoJSON?: string, documentUrlExpression?: string) => - `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON, documentUrlExpression)})`; +const mountCodeFunction = ( + flag: string, + code: string, + scriptInfoJSON?: string, + documentUrlExpression?: string, + documentIdExpression?: string +) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON, documentUrlExpression, documentIdExpression)})`; const ZFunction = Function; @@ -319,6 +335,7 @@ export function compilePreInjectScript( ? embeddedPatternCheckerString("location.href", JSON.stringify(scriptUrlPatterns)) : "true"; const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; + const documentIdExpression = `(()=>{const k='${preInjectScriptDocumentIdKey}',d=Object.getOwnPropertyDescriptor(window,k);if(d&&'value'in d&&typeof d.value==='string')return d.value;const v=Date.now().toString(36)+'-'+Math.random().toString(36).slice(2);Object.defineProperty(window,k,{configurable:false,writable:false,value:v});return v})()`; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; return `{ @@ -326,7 +343,7 @@ export function compilePreInjectScript( f = () => { if (!(${urlCondition})) return false; if (!mounted) { - ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON, "location.href")}; + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON, "location.href", documentIdExpression)}; mounted = true; } const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, From 6ee8ba4b36a752b4f0eb3c5c94c4017b84db6e0c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:36:09 +0900 Subject: [PATCH 094/106] =?UTF-8?q?=F0=9F=94=92=20require=20a=20native=20M?= =?UTF-8?q?AIN=20page-load=20handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/inject.ts | 68 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/src/inject.ts b/src/inject.ts index ff8049211..fe6f198db 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -17,6 +17,8 @@ import { createMainWorldPageLoadGate } from "./app/service/content/main_world_pa const messageFlag = process.env.SC_RANDOM_KEY!; +const NATIVE_BOOTSTRAP_TIMEOUT_MS = 1000; + getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.inject; @@ -46,10 +48,27 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde let reconnecting = false; let openingNative = false; let nativeConnection: MessageConnect | undefined; + let pendingNativeReady: + | { + resolve: (connected: boolean) => void; + timer: ReturnType; + } + | undefined; let reconnectToken: string | undefined; + const settleNativeReady = (connected: boolean): void => { + const pending = pendingNativeReady; + if (!pending) return; + pendingNativeReady = undefined; + clearTimeout(pending.timer); + pending.resolve(connected); + }; + const handleNativePacket = (_connection: MessageConnect, packet: TMessage) => { if (packet.action === "inject/pageLoad") { + if (!pendingNativeReady) return; + nativeConnection = _connection; + settleNativeReady(true); const nextToken = runtime.receivePageLoad(packet.data); if (nextToken) reconnectToken = nextToken; } else if (packet.action === "inject/runtime/valueUpdate") { @@ -62,14 +81,26 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde const openNativeChannel = async (bootstrapToken: string): Promise => { if (openingNative || nativeConnection) return Boolean(nativeConnection); openingNative = true; - let connection: MessageConnect | undefined; - try { - connection = await connectUserScriptChannel( + return new Promise((resolve) => { + const timer = setTimeout(() => { + const connection = nativeConnection; + nativeConnection = undefined; + settleNativeReady(false); + try { + connection?.disconnect(true); + } catch (error) { + logger.logger().debug("MAIN USER_SCRIPT channel cleanup failed", { error: String(error) }); + } + }, NATIVE_BOOTSTRAP_TIMEOUT_MS); + pendingNativeReady = { resolve, timer }; + + void connectUserScriptChannel( nativeMsg, bootstrapToken, handleNativePacket, (isSelfDisconnected) => { - if (nativeConnection === connection) nativeConnection = undefined; + nativeConnection = undefined; + settleNativeReady(false); if (isSelfDisconnected || reconnecting || !reconnectToken) return; reconnecting = true; void requestUserScriptReconnect(nativeMsg, reconnectToken) @@ -80,15 +111,26 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde }); }, "MAIN" - ); - nativeConnection = connection; - return connection !== undefined; - } catch (error) { - logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); - return false; - } finally { - openingNative = false; - } + ) + .then((connection) => { + if (!connection) { + settleNativeReady(false); + return; + } + if (pendingNativeReady || nativeConnection === connection) { + nativeConnection = connection; + return; + } + connection.disconnect(true); + }) + .catch((error) => { + logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); + settleNativeReady(false); + }) + .finally(() => { + openingNative = false; + }); + }); }; if (pageServer) { From babadbc917d3f3608df4fa2be6d951303a269b3f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:22:32 +0900 Subject: [PATCH 095/106] =?UTF-8?q?=E2=9C=85=20bound=20E2E=20waits=20and?= =?UTF-8?q?=20replay=20invariant=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace replay-cap loops with constant-time full-state invariant checks and enforce a 40-second E2E budget across Playwright configuration, helper waits, and CI. Make retry helpers side-effect bounded and preserve failure logs while closing pages on all paths. --- .github/workflows/test.yaml | 3 + e2e/agent-conversation.spec.ts | 8 +- e2e/agent-error-handling.spec.ts | 4 +- e2e/gm-api.spec.ts | 64 ++++++++-------- e2e/gm-xhr-site-access.spec.ts | 37 +++++---- e2e/options.spec.ts | 2 +- e2e/resource-update.spec.ts | 40 +++++----- e2e/storage-name.spec.ts | 2 +- e2e/utils.ts | 20 +++-- package.json | 1 + playwright.config.ts | 2 +- scripts/check-e2e-budgets.mjs | 75 +++++++++++++++++++ src/app/service/content/page_rpc.test.ts | 17 ++--- .../service_worker/gm_api/gm_api.test.ts | 26 ++----- 14 files changed, 192 insertions(+), 109 deletions(-) create mode 100644 scripts/check-e2e-budgets.mjs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 586cbafe9..172734b9a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -211,6 +211,9 @@ jobs: - name: Install dependencies run: pnpm i --frozen-lockfile + - name: Check E2E test budgets + run: pnpm run check:e2e-budgets + - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v6 diff --git a/e2e/agent-conversation.spec.ts b/e2e/agent-conversation.spec.ts index d47957da0..ee44c2d7b 100644 --- a/e2e/agent-conversation.spec.ts +++ b/e2e/agent-conversation.spec.ts @@ -5,7 +5,7 @@ import { runInlineTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; test.describe("Agent Conversation API", () => { - test.setTimeout(300_000); + test.setTimeout(40_000); test("basic chat — send message and receive text reply", async ({ context, extensionId, mockLLMResponse }) => { mockLLMResponse(() => makeTextSSE("1+1等于2。")); @@ -47,7 +47,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); console.log(`[agent-basic-chat] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-basic-chat] logs:", logs.join("\n")); @@ -131,7 +131,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); console.log(`[agent-tool-calling] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-tool-calling] logs:", logs.join("\n")); @@ -190,7 +190,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); console.log(`[agent-multi-turn] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-multi-turn] logs:", logs.join("\n")); diff --git a/e2e/agent-error-handling.spec.ts b/e2e/agent-error-handling.spec.ts index 3b76a23e6..9593af202 100644 --- a/e2e/agent-error-handling.spec.ts +++ b/e2e/agent-error-handling.spec.ts @@ -5,7 +5,7 @@ import { runInlineTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; test.describe("Agent Error Handling", () => { - test.setTimeout(300_000); + test.setTimeout(40_000); test("LLM returns 500 then retries and succeeds", async ({ context, extensionId, mockLLMResponse }) => { let callCount = 0; @@ -79,7 +79,7 @@ test.describe("Agent Error Handling", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 90_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); console.log(`[error-retry] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[error-retry] logs:", logs.join("\n")); diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 2a8194060..ee66afd34 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -563,6 +563,7 @@ async function runTestScript( beforeCollect?: (page: Page) => Promise; } ): Promise<{ summary: SCTestSummary; logs: string[] }> { + if (timeoutMs > 40_000) throw new RangeError("SCTest E2E wait exceeds 40000ms"); let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); code = patchScriptCode(code); if (options?.requireOrigin) code = patchRequireCode(code, options.requireOrigin); @@ -577,6 +578,11 @@ async function runTestScript( let summary: SCTestSummary | null = null; let summaryCount = 0; + const deadline = Date.now() + timeoutMs; + const waitFor = async (predicate: () => boolean): Promise => { + const remaining = Math.max(1, deadline - Date.now()); + await expect.poll(predicate, { timeout: remaining, intervals: [100, 250, 500, 1_000] }).toBe(true); + }; page.on("console", (msg) => { const text = msg.text(); @@ -592,30 +598,24 @@ async function runTestScript( } }); - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - - if (options?.beforeCollect) { - // 顺序很重要:先等页面加载时那组汇总打完(那时 auto:false 的用例还全是 skip, - // 汇总是 "通过: 0 / 失败: 0"),再点按钮,最后等下一组汇总。 - // 若在 goto 之后立刻取快照,首次汇总往往还没打,会让第二个轮询被它立即满足而读到 0/0。 - await expect - .poll(() => summaryCount > 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); - const seenBefore = summaryCount; - await options.beforeCollect(page); - await expect - .poll(() => summaryCount > seenBefore, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); - } else { - await expect - .poll(() => summary !== null, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); + try { + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + if (options?.beforeCollect) { + // 顺序很重要:先等页面加载时那组汇总打完(那时 auto:false 的用例还全是 skip, + // 汇总是 "通过: 0 / 失败: 0"),再点按钮,最后等下一组汇总。 + // 若在 goto 之后立刻取快照,首次汇总往往还没打,会让第二个轮询被它立即满足而读到 0/0。 + await waitFor(() => summaryCount > 0); + const seenBefore = summaryCount; + await options.beforeCollect(page); + await waitFor(() => summaryCount > seenBefore); + } else { + await waitFor(() => summary !== null); + } + } catch (error) { + throw new Error(`No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`, { cause: error }); + } finally { + await page.close(); } - - await page.close(); expect(summary, `No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`).not.toBeNull(); return { summary: summary!, logs }; } @@ -653,7 +653,7 @@ test.describe("GM API", () => { return patchGMApiTestCode(code, gmApiMockServer.origin); } - test.setTimeout(300_000); + test.setTimeout(40_000); test("local CSP target blocks page inline scripts", async ({ context }) => { const page = await context.newPage(); @@ -857,7 +857,7 @@ test.describe("GM API", () => { extensionId, "gm_api_sync_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_sync`, - 90_000, + 30_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -875,7 +875,7 @@ test.describe("GM API", () => { extensionId, "gm_api_async_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_async`, - 90_000, + 30_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -893,7 +893,7 @@ test.describe("GM API", () => { extensionId, "inject_content_test.js", `${gmApiMockServer.cspOrigin}/?inject_content`, - 60_000, + 30_000, { requireOrigin: gmApiMockServer.origin } ); @@ -911,7 +911,7 @@ test.describe("GM API", () => { extensionId, "early_inject_page_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_page`, - 60_000, + 30_000, { requireOrigin: gmApiMockServer.origin } ); @@ -926,7 +926,7 @@ test.describe("GM API", () => { extensionId, "early_inject_content_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_content`, - 60_000, + 30_000, { requireOrigin: gmApiMockServer.origin } ); @@ -941,7 +941,7 @@ test.describe("GM API", () => { extensionId, "unwrap_e2e_test.js", `${gmApiMockServer.cspOrigin}/?unwrap_e2e_test`, - 60_000, + 30_000, { requireOrigin: gmApiMockServer.origin } ); @@ -995,7 +995,7 @@ test.describe("GM API", () => { extensionId, "gm_xhr_redirect_test.js", `${gmApiMockServer.origin}/?GM_XHR_REDIRECT_TEST_SC`, - 90_000, + 30_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -1014,7 +1014,7 @@ test.describe("GM API", () => { "gm_xhr_test.js", `${gmApiMockServer.origin}/?GM_XHR_TEST_SC`, // 138 个用例(69 个基础用例 × xhr/fetch 两轮),其中含多个秒级的 delay/drip 端点。 - 180_000, + 30_000, { patchCode, requireOrigin: gmApiMockServer.origin, diff --git a/e2e/gm-xhr-site-access.spec.ts b/e2e/gm-xhr-site-access.spec.ts index f9b0d2a0d..000b8fddc 100644 --- a/e2e/gm-xhr-site-access.spec.ts +++ b/e2e/gm-xhr-site-access.spec.ts @@ -46,21 +46,28 @@ async function runXhr( } }); - await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll( - async () => { - if (resolved) return true; - await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); - return !!resolved; - }, - { timeout: timeoutMs, intervals: [500, 1_000, 1_500] } - ) - .toBe(true) - .catch(() => undefined); - await page.close(); - if (!resolved) throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`); - return { data: resolved, logs }; + try { + await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" }); + const firstAttemptTimeout = Math.max(1_000, Math.floor(timeoutMs / 2)); + try { + await expect + .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) + .toBe(true); + } catch (error) { + if (resolved) throw error; + try { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect + .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) + .toBe(true); + } catch (retryError) { + throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`, { cause: retryError }); + } + } + return { data: resolved!, logs }; + } finally { + await page.close(); + } } function xhrScript(opts: { diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts index 4016e4a03..16bd0111f 100644 --- a/e2e/options.spec.ts +++ b/e2e/options.spec.ts @@ -91,7 +91,7 @@ test.describe("Options 选项页 · 触摸设备", () => { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true, - timeout: 60_000, + timeout: 40_000, }); try { await context.addInitScript(() => { diff --git a/e2e/resource-update.spec.ts b/e2e/resource-update.spec.ts index c73409a06..dbe82b91b 100644 --- a/e2e/resource-update.spec.ts +++ b/e2e/resource-update.spec.ts @@ -30,7 +30,7 @@ async function waitForHit(server: MockServer, pathname: string, timeoutMs = 15_0 /** * 打开目标页面并等待脚本输出哨兵 JSON 行。脚本注入相对安装存在异步窗口, - * 因此在拿不到结果时重新加载页面重试。 + * 因此只允许一次显式重载;轮询本身保持无副作用,避免重复导航放大等待时间。 */ async function runAndCapture( context: BrowserContext, @@ -53,22 +53,28 @@ async function runAndCapture( } }); - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll( - async () => { - if (resolved) return true; - // 脚本可能尚未注册完成,重载重试 - await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); - return !!resolved; - }, - { timeout: timeoutMs, intervals: [500, 1_000, 2_000] } - ) - .toBe(true) - .catch(() => undefined); - await page.close(); - if (!resolved) throw new Error(`no sentinel captured from ${targetUrl}\nlogs:\n${logs.join("\n")}`); - return { data: resolved, logs }; + try { + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + const firstAttemptTimeout = Math.max(1_000, Math.floor(timeoutMs / 2)); + try { + await expect + .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) + .toBe(true); + } catch (error) { + if (resolved) throw error; + try { + await page.reload({ waitUntil: "domcontentloaded" }); + await expect + .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) + .toBe(true); + } catch (retryError) { + throw new Error(`no sentinel captured from ${targetUrl}\nlogs:\n${logs.join("\n")}`, { cause: retryError }); + } + } + return { data: resolved!, logs }; + } finally { + await page.close(); + } } function selfTestScript(opts: { diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts index 9855fabb4..6e1731177 100644 --- a/e2e/storage-name.spec.ts +++ b/e2e/storage-name.spec.ts @@ -427,7 +427,7 @@ async function runScriptAction(page: Page, action: "deletes" | "purges" | "re } test.describe("@storageName 真实浏览器共享存储", () => { - test.setTimeout(180_000); + test.setTimeout(40_000); test("普通脚本应按 storageName 共享或隔离值与变更事件", async ({ context, extensionId }) => { await serveTargetPage(context); diff --git a/e2e/utils.ts b/e2e/utils.ts index fc54e4c46..17ee2287e 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -1,5 +1,7 @@ import { expect, type BrowserContext, type Frame, type Page } from "@playwright/test"; +const MAX_E2E_WAIT_MS = 40_000; + /** * Auto-approve permission confirm dialogs opened by the extension. * Listens for new pages matching confirm.html (new-ui / shadcn) and grants the request: @@ -61,6 +63,7 @@ export async function runInlineTestScript( targetUrl: string, timeoutMs: number ): Promise<{ passed: number; failed: number; logs: string[] }> { + if (timeoutMs > MAX_E2E_WAIT_MS) throw new RangeError(`Inline E2E wait exceeds ${MAX_E2E_WAIT_MS}ms`); await installScriptByCode(context, extensionId, code); autoApprovePermissions(context); @@ -78,13 +81,16 @@ export async function runInlineTestScript( if (failMatch) failed = parseInt(failMatch[1], 10); }); - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); - - await page.close(); + try { + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + await expect + .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true); + } catch (error) { + throw new Error(`Inline E2E script did not report a result:\n${logs.join("\n")}`, { cause: error }); + } finally { + await page.close(); + } return { passed, failed, logs }; } diff --git a/package.json b/package.json index 527e03682..c0fc5b7da 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", "test:e2e:ui": "pnpm exec playwright test --ui", + "check:e2e-budgets": "node ./scripts/check-e2e-budgets.mjs", "validate:yaml": "node ./scripts/validate-yaml.mjs", "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", "check:i18n": "node ./scripts/check-i18n.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index 1fe7b7e7b..fefd413b1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ // 一次性验证脚本放在 e2e/scratch/(已 gitignore),不纳入正式 E2E 套件/CI。 // 单跑请用 playwright.scratch.config.ts:见 docs/verification.md。 testIgnore: ["**/scratch/**"], - timeout: 60_000, + timeout: 40_000, expect: { timeout: 10_000, }, diff --git a/scripts/check-e2e-budgets.mjs b/scripts/check-e2e-budgets.mjs new file mode 100644 index 000000000..9a5e5c0b3 --- /dev/null +++ b/scripts/check-e2e-budgets.mjs @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import ts from "typescript"; + +const root = process.cwd(); +const maxTestTimeoutMs = 40_000; +const checkedHelpers = new Map([ + ["runInlineTestScript", 4], + ["runTestScript", 4], +]); +const violations = []; + +const numericValue = (node) => { + if (ts.isNumericLiteral(node)) return Number(node.text.replaceAll("_", "")); + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.PlusToken) return numericValue(node.operand); + return undefined; +}; + +const callName = (expression) => { + if (ts.isIdentifier(expression)) return expression.text; + if (!ts.isPropertyAccessExpression(expression)) return undefined; + const owner = callName(expression.expression); + return owner ? `${owner}.${expression.name.text}` : expression.name.text; +}; + +const report = (sourceFile, node, label, value) => { + const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + violations.push( + `${path.relative(root, sourceFile.fileName)}:${position.line + 1}: ${label} is ${value}ms (maximum ${maxTestTimeoutMs}ms)` + ); +}; + +const checkFile = (fileName) => { + const source = fs.readFileSync(fileName, "utf8"); + const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const visit = (node) => { + if (ts.isCallExpression(node)) { + const name = callName(node.expression); + if (name === "test.setTimeout") { + const value = numericValue(node.arguments[0]); + if (value !== undefined && value > maxTestTimeoutMs) report(sourceFile, node, "test timeout", value); + } + const helperArgument = checkedHelpers.get(name); + if (helperArgument !== undefined) { + const value = numericValue(node.arguments[helperArgument]); + if (value !== undefined && value > maxTestTimeoutMs) report(sourceFile, node, `${name} timeout`, value); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); +}; + +const e2eDir = path.join(root, "e2e"); +for (const entry of fs.readdirSync(e2eDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith(".spec.ts")) checkFile(path.join(e2eDir, entry.name)); +} + +const configFile = path.join(root, "playwright.config.ts"); +const configSource = fs.readFileSync(configFile, "utf8"); +const configMatch = configSource.match(/\btimeout\s*:\s*([0-9][0-9_]*)/); +const configTimeout = configMatch ? Number(configMatch[1].replaceAll("_", "")) : undefined; +if (configTimeout === undefined) { + violations.push("playwright.config.ts: missing a numeric global timeout"); +} else if (configTimeout > maxTestTimeoutMs) { + violations.push(`playwright.config.ts: global timeout is ${configTimeout}ms (maximum ${maxTestTimeoutMs}ms)`); +} + +if (violations.length > 0) { + console.error(violations.join("\n")); + process.exitCode = 1; +} else { + console.log(`E2E test budgets are capped at ${maxTestTimeoutMs}ms.`); +} diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts index 2aa749caf..40cb5c58e 100644 --- a/src/app/service/content/page_rpc.test.ts +++ b/src/app/service/content/page_rpc.test.ts @@ -363,20 +363,19 @@ describe("page GM RPC", () => { } }); - it("accepts more than 4096 requests while still rejecting replay", () => { + it("accepts a unique request when replay state is full while still rejecting replay", () => { const registry = new PageRpcRegistry(); const handle = registry.register("script-a", "it", ["GM_getValue"]); + const binding = registry.resolve(handle, "GM_getValue"); - for (let index = 0; index < 4096; index += 1) { - validatePageGMRequest( - { version: 1, requestId: `request-${index}`, handle, api: "GM_getValue", params: [] }, - registry - ); - } + // 请求 ID 必须严格只消费一次,与集合已保存的条目数量无关。 + // 直接模拟满集合,避免 CI 为构造状态发送数千个请求。 + Object.defineProperty(binding.requestIds, "size", { configurable: true, value: 4096 }); + binding.requestIds.add("request-0"); expect( - validatePageGMRequest({ version: 1, requestId: "request-4096", handle, api: "GM_getValue", params: [] }, registry) - ).toMatchObject({ requestId: "request-4096" }); + validatePageGMRequest({ version: 1, requestId: "request-4097", handle, api: "GM_getValue", params: [] }, registry) + ).toMatchObject({ requestId: "request-4097" }); expect(() => validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) ).toThrow("already used"); diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 000064bc6..dc1d00fff 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -337,7 +337,7 @@ describe("page execution binding gate", () => { await expect(api.handlerRequest(validRequest, sender)).rejects.toThrow("page RPC requestId was already used"); }); - it("accepts more than 4096 page RPC requests while still rejecting replay", async () => { + it("accepts a unique request when replay state is full while still rejecting replay", async () => { const api = Object.create(GMApi.prototype) as GMApi; Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); Object.defineProperty(api, "permissionVerify", { @@ -353,6 +353,9 @@ describe("page execution binding gate", () => { script: { uuid: "script-a", name: "script-a" }, }), }); + const requestIds = new Set(["request-0"]); + // 直接模拟满集合,验证唯一 ID 仍可用且重放仍被拒绝,避免 CI 发送数千个请求。 + Object.defineProperty(requestIds, "size", { configurable: true, value: 4096 }); const binding = { handle: "handle-a", uuid: "script-a", @@ -361,7 +364,7 @@ describe("page execution binding gate", () => { tabId: 42, frameId: 0, allowedAPIs: new Set(["GM_log"]), - requestIds: new Set(), + requestIds, }; Object.defineProperty(api, "resolvePageExecutionBinding", { configurable: true, @@ -370,23 +373,6 @@ describe("page execution binding gate", () => { const sender = makeSender(); sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); - for (let index = 0; index < 4096; index += 1) { - await expect( - api.handlerRequest( - { - uuid: "script-a", - api: "GM_log", - params: ["hello"], - runFlag: "forged", - executionHandle: "handle-a", - requestId: `request-${index}`, - version: 1, - }, - sender - ) - ).resolves.toBe(true); - } - await expect( api.handlerRequest( { @@ -395,7 +381,7 @@ describe("page execution binding gate", () => { params: ["hello"], runFlag: "forged", executionHandle: "handle-a", - requestId: "request-4096", + requestId: "request-4097", version: 1, }, sender From 0541cdbcb40b6da9fa0b06e20d912cc02a3719d9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:26:26 +0900 Subject: [PATCH 096/106] =?UTF-8?q?=F0=9F=94=A7=20restore=20PR=20E2E=20tim?= =?UTF-8?q?eout=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep CI and helper timeout behavior outside this PR. Retain the constant-time replay invariant tests while restoring the existing E2E configuration and retry semantics. --- .github/workflows/test.yaml | 3 -- e2e/agent-conversation.spec.ts | 8 ++-- e2e/agent-error-handling.spec.ts | 4 +- e2e/gm-api.spec.ts | 64 +++++++++++++-------------- e2e/gm-xhr-site-access.spec.ts | 37 +++++++--------- e2e/options.spec.ts | 2 +- e2e/resource-update.spec.ts | 40 ++++++++--------- e2e/storage-name.spec.ts | 2 +- e2e/utils.ts | 20 +++------ package.json | 1 - playwright.config.ts | 2 +- scripts/check-e2e-budgets.mjs | 75 -------------------------------- 12 files changed, 80 insertions(+), 178 deletions(-) delete mode 100644 scripts/check-e2e-budgets.mjs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 172734b9a..586cbafe9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -211,9 +211,6 @@ jobs: - name: Install dependencies run: pnpm i --frozen-lockfile - - name: Check E2E test budgets - run: pnpm run check:e2e-budgets - - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v6 diff --git a/e2e/agent-conversation.spec.ts b/e2e/agent-conversation.spec.ts index ee44c2d7b..d47957da0 100644 --- a/e2e/agent-conversation.spec.ts +++ b/e2e/agent-conversation.spec.ts @@ -5,7 +5,7 @@ import { runInlineTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; test.describe("Agent Conversation API", () => { - test.setTimeout(40_000); + test.setTimeout(300_000); test("basic chat — send message and receive text reply", async ({ context, extensionId, mockLLMResponse }) => { mockLLMResponse(() => makeTextSSE("1+1等于2。")); @@ -47,7 +47,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); console.log(`[agent-basic-chat] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-basic-chat] logs:", logs.join("\n")); @@ -131,7 +131,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); console.log(`[agent-tool-calling] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-tool-calling] logs:", logs.join("\n")); @@ -190,7 +190,7 @@ test.describe("Agent Conversation API", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); console.log(`[agent-multi-turn] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[agent-multi-turn] logs:", logs.join("\n")); diff --git a/e2e/agent-error-handling.spec.ts b/e2e/agent-error-handling.spec.ts index 9593af202..3b76a23e6 100644 --- a/e2e/agent-error-handling.spec.ts +++ b/e2e/agent-error-handling.spec.ts @@ -5,7 +5,7 @@ import { runInlineTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; test.describe("Agent Error Handling", () => { - test.setTimeout(40_000); + test.setTimeout(300_000); test("LLM returns 500 then retries and succeeds", async ({ context, extensionId, mockLLMResponse }) => { let callCount = 0; @@ -79,7 +79,7 @@ test.describe("Agent Error Handling", () => { })(); `; - const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 30_000); + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 90_000); console.log(`[error-retry] passed=${passed}, failed=${failed}`); if (failed !== 0) console.log("[error-retry] logs:", logs.join("\n")); diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index ee66afd34..2a8194060 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -563,7 +563,6 @@ async function runTestScript( beforeCollect?: (page: Page) => Promise; } ): Promise<{ summary: SCTestSummary; logs: string[] }> { - if (timeoutMs > 40_000) throw new RangeError("SCTest E2E wait exceeds 40000ms"); let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); code = patchScriptCode(code); if (options?.requireOrigin) code = patchRequireCode(code, options.requireOrigin); @@ -578,11 +577,6 @@ async function runTestScript( let summary: SCTestSummary | null = null; let summaryCount = 0; - const deadline = Date.now() + timeoutMs; - const waitFor = async (predicate: () => boolean): Promise => { - const remaining = Math.max(1, deadline - Date.now()); - await expect.poll(predicate, { timeout: remaining, intervals: [100, 250, 500, 1_000] }).toBe(true); - }; page.on("console", (msg) => { const text = msg.text(); @@ -598,24 +592,30 @@ async function runTestScript( } }); - try { - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - if (options?.beforeCollect) { - // 顺序很重要:先等页面加载时那组汇总打完(那时 auto:false 的用例还全是 skip, - // 汇总是 "通过: 0 / 失败: 0"),再点按钮,最后等下一组汇总。 - // 若在 goto 之后立刻取快照,首次汇总往往还没打,会让第二个轮询被它立即满足而读到 0/0。 - await waitFor(() => summaryCount > 0); - const seenBefore = summaryCount; - await options.beforeCollect(page); - await waitFor(() => summaryCount > seenBefore); - } else { - await waitFor(() => summary !== null); - } - } catch (error) { - throw new Error(`No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`, { cause: error }); - } finally { - await page.close(); + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + + if (options?.beforeCollect) { + // 顺序很重要:先等页面加载时那组汇总打完(那时 auto:false 的用例还全是 skip, + // 汇总是 "通过: 0 / 失败: 0"),再点按钮,最后等下一组汇总。 + // 若在 goto 之后立刻取快照,首次汇总往往还没打,会让第二个轮询被它立即满足而读到 0/0。 + await expect + .poll(() => summaryCount > 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true) + .catch(() => undefined); + const seenBefore = summaryCount; + await options.beforeCollect(page); + await expect + .poll(() => summaryCount > seenBefore, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true) + .catch(() => undefined); + } else { + await expect + .poll(() => summary !== null, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true) + .catch(() => undefined); } + + await page.close(); expect(summary, `No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`).not.toBeNull(); return { summary: summary!, logs }; } @@ -653,7 +653,7 @@ test.describe("GM API", () => { return patchGMApiTestCode(code, gmApiMockServer.origin); } - test.setTimeout(40_000); + test.setTimeout(300_000); test("local CSP target blocks page inline scripts", async ({ context }) => { const page = await context.newPage(); @@ -857,7 +857,7 @@ test.describe("GM API", () => { extensionId, "gm_api_sync_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_sync`, - 30_000, + 90_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -875,7 +875,7 @@ test.describe("GM API", () => { extensionId, "gm_api_async_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_async`, - 30_000, + 90_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -893,7 +893,7 @@ test.describe("GM API", () => { extensionId, "inject_content_test.js", `${gmApiMockServer.cspOrigin}/?inject_content`, - 30_000, + 60_000, { requireOrigin: gmApiMockServer.origin } ); @@ -911,7 +911,7 @@ test.describe("GM API", () => { extensionId, "early_inject_page_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_page`, - 30_000, + 60_000, { requireOrigin: gmApiMockServer.origin } ); @@ -926,7 +926,7 @@ test.describe("GM API", () => { extensionId, "early_inject_content_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_content`, - 30_000, + 60_000, { requireOrigin: gmApiMockServer.origin } ); @@ -941,7 +941,7 @@ test.describe("GM API", () => { extensionId, "unwrap_e2e_test.js", `${gmApiMockServer.cspOrigin}/?unwrap_e2e_test`, - 30_000, + 60_000, { requireOrigin: gmApiMockServer.origin } ); @@ -995,7 +995,7 @@ test.describe("GM API", () => { extensionId, "gm_xhr_redirect_test.js", `${gmApiMockServer.origin}/?GM_XHR_REDIRECT_TEST_SC`, - 30_000, + 90_000, { patchCode, requireOrigin: gmApiMockServer.origin } ); @@ -1014,7 +1014,7 @@ test.describe("GM API", () => { "gm_xhr_test.js", `${gmApiMockServer.origin}/?GM_XHR_TEST_SC`, // 138 个用例(69 个基础用例 × xhr/fetch 两轮),其中含多个秒级的 delay/drip 端点。 - 30_000, + 180_000, { patchCode, requireOrigin: gmApiMockServer.origin, diff --git a/e2e/gm-xhr-site-access.spec.ts b/e2e/gm-xhr-site-access.spec.ts index 000b8fddc..f9b0d2a0d 100644 --- a/e2e/gm-xhr-site-access.spec.ts +++ b/e2e/gm-xhr-site-access.spec.ts @@ -46,28 +46,21 @@ async function runXhr( } }); - try { - await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" }); - const firstAttemptTimeout = Math.max(1_000, Math.floor(timeoutMs / 2)); - try { - await expect - .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) - .toBe(true); - } catch (error) { - if (resolved) throw error; - try { - await page.reload({ waitUntil: "domcontentloaded" }); - await expect - .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) - .toBe(true); - } catch (retryError) { - throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`, { cause: retryError }); - } - } - return { data: resolved!, logs }; - } finally { - await page.close(); - } + await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" }); + await expect + .poll( + async () => { + if (resolved) return true; + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); + return !!resolved; + }, + { timeout: timeoutMs, intervals: [500, 1_000, 1_500] } + ) + .toBe(true) + .catch(() => undefined); + await page.close(); + if (!resolved) throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`); + return { data: resolved, logs }; } function xhrScript(opts: { diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts index 16bd0111f..4016e4a03 100644 --- a/e2e/options.spec.ts +++ b/e2e/options.spec.ts @@ -91,7 +91,7 @@ test.describe("Options 选项页 · 触摸设备", () => { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true, - timeout: 40_000, + timeout: 60_000, }); try { await context.addInitScript(() => { diff --git a/e2e/resource-update.spec.ts b/e2e/resource-update.spec.ts index dbe82b91b..c73409a06 100644 --- a/e2e/resource-update.spec.ts +++ b/e2e/resource-update.spec.ts @@ -30,7 +30,7 @@ async function waitForHit(server: MockServer, pathname: string, timeoutMs = 15_0 /** * 打开目标页面并等待脚本输出哨兵 JSON 行。脚本注入相对安装存在异步窗口, - * 因此只允许一次显式重载;轮询本身保持无副作用,避免重复导航放大等待时间。 + * 因此在拿不到结果时重新加载页面重试。 */ async function runAndCapture( context: BrowserContext, @@ -53,28 +53,22 @@ async function runAndCapture( } }); - try { - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - const firstAttemptTimeout = Math.max(1_000, Math.floor(timeoutMs / 2)); - try { - await expect - .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) - .toBe(true); - } catch (error) { - if (resolved) throw error; - try { - await page.reload({ waitUntil: "domcontentloaded" }); - await expect - .poll(() => Boolean(resolved), { timeout: firstAttemptTimeout, intervals: [100, 250, 500] }) - .toBe(true); - } catch (retryError) { - throw new Error(`no sentinel captured from ${targetUrl}\nlogs:\n${logs.join("\n")}`, { cause: retryError }); - } - } - return { data: resolved!, logs }; - } finally { - await page.close(); - } + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + await expect + .poll( + async () => { + if (resolved) return true; + // 脚本可能尚未注册完成,重载重试 + await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); + return !!resolved; + }, + { timeout: timeoutMs, intervals: [500, 1_000, 2_000] } + ) + .toBe(true) + .catch(() => undefined); + await page.close(); + if (!resolved) throw new Error(`no sentinel captured from ${targetUrl}\nlogs:\n${logs.join("\n")}`); + return { data: resolved, logs }; } function selfTestScript(opts: { diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts index 6e1731177..9855fabb4 100644 --- a/e2e/storage-name.spec.ts +++ b/e2e/storage-name.spec.ts @@ -427,7 +427,7 @@ async function runScriptAction(page: Page, action: "deletes" | "purges" | "re } test.describe("@storageName 真实浏览器共享存储", () => { - test.setTimeout(40_000); + test.setTimeout(180_000); test("普通脚本应按 storageName 共享或隔离值与变更事件", async ({ context, extensionId }) => { await serveTargetPage(context); diff --git a/e2e/utils.ts b/e2e/utils.ts index 17ee2287e..fc54e4c46 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -1,7 +1,5 @@ import { expect, type BrowserContext, type Frame, type Page } from "@playwright/test"; -const MAX_E2E_WAIT_MS = 40_000; - /** * Auto-approve permission confirm dialogs opened by the extension. * Listens for new pages matching confirm.html (new-ui / shadcn) and grants the request: @@ -63,7 +61,6 @@ export async function runInlineTestScript( targetUrl: string, timeoutMs: number ): Promise<{ passed: number; failed: number; logs: string[] }> { - if (timeoutMs > MAX_E2E_WAIT_MS) throw new RangeError(`Inline E2E wait exceeds ${MAX_E2E_WAIT_MS}ms`); await installScriptByCode(context, extensionId, code); autoApprovePermissions(context); @@ -81,16 +78,13 @@ export async function runInlineTestScript( if (failMatch) failed = parseInt(failMatch[1], 10); }); - try { - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true); - } catch (error) { - throw new Error(`Inline E2E script did not report a result:\n${logs.join("\n")}`, { cause: error }); - } finally { - await page.close(); - } + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + await expect + .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true) + .catch(() => undefined); + + await page.close(); return { passed, failed, logs }; } diff --git a/package.json b/package.json index c0fc5b7da..527e03682 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", "test:e2e:ui": "pnpm exec playwright test --ui", - "check:e2e-budgets": "node ./scripts/check-e2e-budgets.mjs", "validate:yaml": "node ./scripts/validate-yaml.mjs", "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", "check:i18n": "node ./scripts/check-i18n.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index fefd413b1..1fe7b7e7b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ // 一次性验证脚本放在 e2e/scratch/(已 gitignore),不纳入正式 E2E 套件/CI。 // 单跑请用 playwright.scratch.config.ts:见 docs/verification.md。 testIgnore: ["**/scratch/**"], - timeout: 40_000, + timeout: 60_000, expect: { timeout: 10_000, }, diff --git a/scripts/check-e2e-budgets.mjs b/scripts/check-e2e-budgets.mjs deleted file mode 100644 index 9a5e5c0b3..000000000 --- a/scripts/check-e2e-budgets.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import ts from "typescript"; - -const root = process.cwd(); -const maxTestTimeoutMs = 40_000; -const checkedHelpers = new Map([ - ["runInlineTestScript", 4], - ["runTestScript", 4], -]); -const violations = []; - -const numericValue = (node) => { - if (ts.isNumericLiteral(node)) return Number(node.text.replaceAll("_", "")); - if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.PlusToken) return numericValue(node.operand); - return undefined; -}; - -const callName = (expression) => { - if (ts.isIdentifier(expression)) return expression.text; - if (!ts.isPropertyAccessExpression(expression)) return undefined; - const owner = callName(expression.expression); - return owner ? `${owner}.${expression.name.text}` : expression.name.text; -}; - -const report = (sourceFile, node, label, value) => { - const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); - violations.push( - `${path.relative(root, sourceFile.fileName)}:${position.line + 1}: ${label} is ${value}ms (maximum ${maxTestTimeoutMs}ms)` - ); -}; - -const checkFile = (fileName) => { - const source = fs.readFileSync(fileName, "utf8"); - const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - const visit = (node) => { - if (ts.isCallExpression(node)) { - const name = callName(node.expression); - if (name === "test.setTimeout") { - const value = numericValue(node.arguments[0]); - if (value !== undefined && value > maxTestTimeoutMs) report(sourceFile, node, "test timeout", value); - } - const helperArgument = checkedHelpers.get(name); - if (helperArgument !== undefined) { - const value = numericValue(node.arguments[helperArgument]); - if (value !== undefined && value > maxTestTimeoutMs) report(sourceFile, node, `${name} timeout`, value); - } - } - ts.forEachChild(node, visit); - }; - visit(sourceFile); -}; - -const e2eDir = path.join(root, "e2e"); -for (const entry of fs.readdirSync(e2eDir, { withFileTypes: true })) { - if (entry.isFile() && entry.name.endsWith(".spec.ts")) checkFile(path.join(e2eDir, entry.name)); -} - -const configFile = path.join(root, "playwright.config.ts"); -const configSource = fs.readFileSync(configFile, "utf8"); -const configMatch = configSource.match(/\btimeout\s*:\s*([0-9][0-9_]*)/); -const configTimeout = configMatch ? Number(configMatch[1].replaceAll("_", "")) : undefined; -if (configTimeout === undefined) { - violations.push("playwright.config.ts: missing a numeric global timeout"); -} else if (configTimeout > maxTestTimeoutMs) { - violations.push(`playwright.config.ts: global timeout is ${configTimeout}ms (maximum ${maxTestTimeoutMs}ms)`); -} - -if (violations.length > 0) { - console.error(violations.join("\n")); - process.exitCode = 1; -} else { - console.log(`E2E test budgets are capped at ${maxTestTimeoutMs}ms.`); -} From 3217aca128ef2d7366fb3d3be205f3eeb6e0950c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:59:29 +0900 Subject: [PATCH 097/106] =?UTF-8?q?=F0=9F=94=92=20route=20MAIN=20bootstrap?= =?UTF-8?q?=20over=20extension=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/user_script_connection.test.ts | 19 +++++++++++++++++-- .../service/content/user_script_connection.ts | 5 +++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts index 8689a7424..d68ef656d 100644 --- a/src/app/service/content/user_script_connection.test.ts +++ b/src/app/service/content/user_script_connection.test.ts @@ -31,7 +31,7 @@ describe("connectUserScriptChannel", () => { expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); }); - it("preserves the MAIN world identity when opening the inject port", async () => { + it("uses the constrained extension transport for the MAIN world port", async () => { const connection = makeConnection(); const message = { sendMessage: vi.fn().mockResolvedValue(true), @@ -42,7 +42,7 @@ describe("connectUserScriptChannel", () => { expect(message.connect).toHaveBeenCalledWith({ action: "serviceWorker/runtime/registerUserScript", - data: { world: "MAIN", bootstrapToken: "inject-bootstrap" }, + data: { world: "MAIN", bootstrapToken: "inject-bootstrap", transport: "extension" }, }); }); @@ -75,6 +75,21 @@ describe("connectUserScriptChannel", () => { expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); }); + it("uses the extension fallback when listener capability probing has no response", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn(), undefined, "MAIN"); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "MAIN", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + it("reports remote disconnects so the caller can reconnect natively", async () => { const connection = makeConnection(); const onDisconnect = vi.fn(); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts index b3fdb30a4..61bf2933a 100644 --- a/src/app/service/content/user_script_connection.ts +++ b/src/app/service/content/user_script_connection.ts @@ -21,15 +21,16 @@ export async function connectUserScriptChannel( world: UserScriptWorld = "USER_SCRIPT" ): Promise { const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); + const useExtensionFallback = world === "MAIN" || enabled !== true; let connection: MessageConnect; try { // 缺少专用 USER_SCRIPT 监听器时仍使用扩展原生端口;服务端会用文档绑定的令牌限制该降级路径。 connection = await message.connect({ action: "serviceWorker/runtime/registerUserScript", - data: enabled === false ? { world, bootstrapToken, transport: "extension" } : { world, bootstrapToken }, + data: useExtensionFallback ? { world, bootstrapToken, transport: "extension" } : { world, bootstrapToken }, }); } catch (error) { - if (enabled !== false) throw error; + if (!useExtensionFallback) throw error; return undefined; } connection.onMessage((packet) => onPacket(connection, packet)); From dbad9926c4a6baf790e2877c215ef28e84712ec4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:57:32 +0900 Subject: [PATCH 098/106] =?UTF-8?q?=F0=9F=94=92=20restore=20MAIN=20bootstr?= =?UTF-8?q?ap=20page=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 7 +++++-- src/inject.ts | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index ee2e73c6b..9b2392227 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -6,6 +6,10 @@ const unsupportedAPI = () => { // 在页面或用户脚本替换调用内建函数前完成捕获。 export const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; +const hasNativeStructuredClone = typeof structuredClone === "function"; +const nativeStructuredClone = hasNativeStructuredClone + ? nativeReflectApply(nativeFunctionBind, structuredClone, [globalThis]) + : unsupportedAPI; const nativeSetConstructor = Set; const nativeSetAdd = Set.prototype.add; const nativeSetHas = Set.prototype.has; @@ -29,7 +33,6 @@ const nativeWeakMapDelete = WeakMap.prototype.delete; const nativeObjectFreeze = Object.freeze; const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; -const hasNativeStructuredClone = typeof structuredClone === "function"; const nativeDocumentCreateElement = typeof Document === "undefined" ? undefined : Document.prototype.createElement; const nativeOwnFragment = typeof DocumentFragment === "undefined" ? undefined : new DocumentFragment(); @@ -89,7 +92,7 @@ export const Native = { WeakMap: NativeWeakMapConstructor, bind: nativeBind, reflectApply: nativeReflectApply, - structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, + structuredClone: nativeStructuredClone, jsonStringify: nativeBind(JSON.stringify, JSON), jsonParse: nativeBind(JSON.parse, JSON), createElement: nativeDocumentCreateElement, diff --git a/src/inject.ts b/src/inject.ts index fe6f198db..ec44e611c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -147,14 +147,14 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde pageLoadGate.onBootstrap(data.bootstrapToken); }); pageServer.on("pageLoad", pageLoadGate.onPageLoad); - } else { + } + runtime.init(); + if (!pageServer) { // 没有原生 runtime 通道时,bootstrap 只作为页面桥上的兼容握手,随后请求完整 pageLoad。 server.on("bootstrap", () => { void new Client(pageMsg, "scripting").do("pageLoadFallback"); }); } - runtime.init(); - // inject环境,直接判断白名单,注入对外接口 runtime.externalMessage("scripting", pageMsg); }); From 0057ee83abb0b8201aa9214953ecdf637cad6576 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:14:33 +0900 Subject: [PATCH 099/106] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=B7=A8=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B6=88=E6=81=AF=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=83=AD=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.ts | 129 +++++++++++++++++++++-------- packages/message/window_message.ts | 82 +++++++++++------- 2 files changed, 148 insertions(+), 63 deletions(-) diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts index 14c8ed308..807fe96bf 100644 --- a/packages/message/page_message.ts +++ b/packages/message/page_message.ts @@ -32,51 +32,112 @@ const listenerMgr = new EventEmitter(); const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; -const PAGE_MESSAGE_KEYS = ["channel", "source", "target", "messageId", "type", "data"] as const; const parsePageMessageBody = (value: unknown): PageMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; - let keys: (string | symbol)[]; + let channel: unknown; + let source: unknown; + let target: unknown; + let messageId: unknown; + let type: unknown; + let data: unknown; try { - keys = nativeReflectOwnKeys(value); - } catch { - return undefined; - } - if (keys.length !== PAGE_MESSAGE_KEYS.length) return undefined; - for (let index = 0; index < keys.length; index += 1) { - const key = keys[index]; - let known = false; - if (typeof key === "string") { - for (let expectedIndex = 0; expectedIndex < PAGE_MESSAGE_KEYS.length; expectedIndex += 1) { - if (PAGE_MESSAGE_KEYS[expectedIndex] === key) { - known = true; - break; + const keys = nativeReflectOwnKeys(value); + if (keys.length !== 6) return undefined; + // sendEnvelope 按该字面量顺序构造;保留乱序校验作为兼容路径。 + if ( + keys[0] === "channel" && + keys[1] === "source" && + keys[2] === "target" && + keys[3] === "messageId" && + keys[4] === "type" && + keys[5] === "data" + ) { + const channelDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "channel"); + const sourceDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "source"); + const targetDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "target"); + const messageIdDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const typeDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if ( + !channelDescriptor || + !("value" in channelDescriptor) || + !sourceDescriptor || + !("value" in sourceDescriptor) || + !targetDescriptor || + !("value" in targetDescriptor) || + !messageIdDescriptor || + !("value" in messageIdDescriptor) || + !typeDescriptor || + !("value" in typeDescriptor) || + !dataDescriptor || + !("value" in dataDescriptor) + ) { + return undefined; + } + channel = channelDescriptor.value; + source = sourceDescriptor.value; + target = targetDescriptor.value; + messageId = messageIdDescriptor.value; + type = typeDescriptor.value; + data = dataDescriptor.value; + } else { + let seen = 0; + for (let index = 0; index < 6; index += 1) { + const key = keys[index]; + switch (key) { + case "channel": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + channel = descriptor.value; + seen |= 1; + break; + } + case "source": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + source = descriptor.value; + seen |= 2; + break; + } + case "target": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + target = descriptor.value; + seen |= 4; + break; + } + case "messageId": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + messageId = descriptor.value; + seen |= 8; + break; + } + case "type": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + type = descriptor.value; + seen |= 16; + break; + } + case "data": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + data = descriptor.value; + seen |= 32; + break; + } + default: + return undefined; } } - } - if (!known) { - return undefined; - } - } - - let fields: PropertyDescriptor[]; - try { - fields = []; - for (let index = 0; index < PAGE_MESSAGE_KEYS.length; index += 1) { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, PAGE_MESSAGE_KEYS[index]); - if (!descriptor || !("value" in descriptor)) return undefined; - fields[fields.length] = descriptor; + if (seen !== 63) return undefined; } } catch { return undefined; } - const channel = fields[0].value; - const source = fields[1].value; - const target = fields[2].value; - const messageId = fields[3].value; - const type = fields[4].value; - const data = fields[5].value; if ( typeof channel !== "string" || (source !== "scripting" && source !== "inject") || diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 40cd2d8e5..e4c841ded 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -34,45 +34,69 @@ export type WindowMessageBody = { const nativeReflectOwnKeys = Reflect.ownKeys; const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; -const WINDOW_MESSAGE_KEYS = ["messageId", "type", "data"] as const; export const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; - let keys: (string | symbol)[]; + let messageId: unknown; + let type: unknown; + let data: unknown; try { - keys = nativeReflectOwnKeys(value); - } catch { - return undefined; - } - if (keys.length !== WINDOW_MESSAGE_KEYS.length) return undefined; - for (let index = 0; index < keys.length; index += 1) { - const key = keys[index]; - let known = false; - if (typeof key === "string") { - for (let expectedIndex = 0; expectedIndex < WINDOW_MESSAGE_KEYS.length; expectedIndex += 1) { - if (WINDOW_MESSAGE_KEYS[expectedIndex] === key) { - known = true; - break; + const keys = nativeReflectOwnKeys(value); + if (keys.length !== 3) return undefined; + // 项目内消息按该字面量顺序构造;保留乱序校验作为兼容路径。 + if (keys[0] === "messageId" && keys[1] === "type" && keys[2] === "data") { + const messageIdDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const typeDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if ( + !messageIdDescriptor || + !("value" in messageIdDescriptor) || + !typeDescriptor || + !("value" in typeDescriptor) || + !dataDescriptor || + !("value" in dataDescriptor) + ) { + return undefined; + } + messageId = messageIdDescriptor.value; + type = typeDescriptor.value; + data = dataDescriptor.value; + } else { + let seen = 0; + for (let index = 0; index < 3; index += 1) { + const key = keys[index]; + switch (key) { + case "messageId": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + messageId = descriptor.value; + seen |= 1; + break; + } + case "type": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + type = descriptor.value; + seen |= 2; + break; + } + case "data": { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + data = descriptor.value; + seen |= 4; + break; + } + default: + return undefined; } } - } - if (!known) return undefined; - } - - let fields: PropertyDescriptor[]; - try { - fields = []; - for (let index = 0; index < WINDOW_MESSAGE_KEYS.length; index += 1) { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, WINDOW_MESSAGE_KEYS[index]); - if (!descriptor || !("value" in descriptor)) return undefined; - fields[fields.length] = descriptor; + if (seen !== 7) return undefined; } } catch { return undefined; } - const messageId = fields[0].value; - const type = fields[1].value; if ( typeof messageId !== "string" || (type !== "sendMessage" && @@ -83,7 +107,7 @@ export const parseWindowMessageBody = (value: unknown): WindowMessageBody | unde ) { return undefined; } - return { messageId, type, data: fields[2].value } as WindowMessageBody; + return { messageId, type, data } as WindowMessageBody; }; export class WindowMessage implements Message { From cf6cf21954c040b46df8508be88ea0d8e30bcd53 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:27:58 +0900 Subject: [PATCH 100/106] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E8=B7=A8=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B6=88=E6=81=AF=E4=BD=93?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.ts | 154 +++++++++-------------------- packages/message/window_message.ts | 86 ++++------------ 2 files changed, 66 insertions(+), 174 deletions(-) diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts index 807fe96bf..9fe9b73d7 100644 --- a/packages/message/page_message.ts +++ b/packages/message/page_message.ts @@ -36,122 +36,58 @@ const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const parsePageMessageBody = (value: unknown): PageMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; - let channel: unknown; - let source: unknown; - let target: unknown; - let messageId: unknown; - let type: unknown; - let data: unknown; try { - const keys = nativeReflectOwnKeys(value); - if (keys.length !== 6) return undefined; - // sendEnvelope 按该字面量顺序构造;保留乱序校验作为兼容路径。 + if (nativeReflectOwnKeys(value).length !== 6) return undefined; + + const channel = nativeObjectGetOwnPropertyDescriptor(value, "channel"); + const source = nativeObjectGetOwnPropertyDescriptor(value, "source"); + const target = nativeObjectGetOwnPropertyDescriptor(value, "target"); + const messageId = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const type = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const data = nativeObjectGetOwnPropertyDescriptor(value, "data"); if ( - keys[0] === "channel" && - keys[1] === "source" && - keys[2] === "target" && - keys[3] === "messageId" && - keys[4] === "type" && - keys[5] === "data" + !channel || + !("value" in channel) || + !source || + !("value" in source) || + !target || + !("value" in target) || + !messageId || + !("value" in messageId) || + !type || + !("value" in type) || + !data || + !("value" in data) ) { - const channelDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "channel"); - const sourceDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "source"); - const targetDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "target"); - const messageIdDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); - const typeDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "type"); - const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); - if ( - !channelDescriptor || - !("value" in channelDescriptor) || - !sourceDescriptor || - !("value" in sourceDescriptor) || - !targetDescriptor || - !("value" in targetDescriptor) || - !messageIdDescriptor || - !("value" in messageIdDescriptor) || - !typeDescriptor || - !("value" in typeDescriptor) || - !dataDescriptor || - !("value" in dataDescriptor) - ) { - return undefined; - } - channel = channelDescriptor.value; - source = sourceDescriptor.value; - target = targetDescriptor.value; - messageId = messageIdDescriptor.value; - type = typeDescriptor.value; - data = dataDescriptor.value; - } else { - let seen = 0; - for (let index = 0; index < 6; index += 1) { - const key = keys[index]; - switch (key) { - case "channel": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - channel = descriptor.value; - seen |= 1; - break; - } - case "source": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - source = descriptor.value; - seen |= 2; - break; - } - case "target": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - target = descriptor.value; - seen |= 4; - break; - } - case "messageId": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - messageId = descriptor.value; - seen |= 8; - break; - } - case "type": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - type = descriptor.value; - seen |= 16; - break; - } - case "data": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - data = descriptor.value; - seen |= 32; - break; - } - default: - return undefined; - } - } - if (seen !== 63) return undefined; + return undefined; } + + const messageType = type.value; + if ( + typeof channel.value !== "string" || + (source.value !== "scripting" && source.value !== "inject") || + (target.value !== "scripting" && target.value !== "inject") || + typeof messageId.value !== "string" || + (messageType !== "sendMessage" && + messageType !== "respMessage" && + messageType !== "connect" && + messageType !== "disconnect" && + messageType !== "connectMessage") + ) { + return undefined; + } + + return { + channel: channel.value, + source: source.value, + target: target.value, + messageId: messageId.value, + type: messageType, + data: data.value, + } as PageMessageBody; } catch { return undefined; } - if ( - typeof channel !== "string" || - (source !== "scripting" && source !== "inject") || - (target !== "scripting" && target !== "inject") || - typeof messageId !== "string" || - (type !== "sendMessage" && - type !== "respMessage" && - type !== "connect" && - type !== "disconnect" && - type !== "connectMessage") - ) { - return undefined; - } - return { channel, source, target, messageId, type, data } as PageMessageBody; }; const otherRole = (role: PageMessageRole): PageMessageRole => (role === "scripting" ? "inject" : "scripting"); diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index e4c841ded..f844100bc 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -38,76 +38,32 @@ const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; export const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { if (value === null || typeof value !== "object") return undefined; - let messageId: unknown; - let type: unknown; - let data: unknown; try { - const keys = nativeReflectOwnKeys(value); - if (keys.length !== 3) return undefined; - // 项目内消息按该字面量顺序构造;保留乱序校验作为兼容路径。 - if (keys[0] === "messageId" && keys[1] === "type" && keys[2] === "data") { - const messageIdDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); - const typeDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "type"); - const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); - if ( - !messageIdDescriptor || - !("value" in messageIdDescriptor) || - !typeDescriptor || - !("value" in typeDescriptor) || - !dataDescriptor || - !("value" in dataDescriptor) - ) { - return undefined; - } - messageId = messageIdDescriptor.value; - type = typeDescriptor.value; - data = dataDescriptor.value; - } else { - let seen = 0; - for (let index = 0; index < 3; index += 1) { - const key = keys[index]; - switch (key) { - case "messageId": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - messageId = descriptor.value; - seen |= 1; - break; - } - case "type": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - type = descriptor.value; - seen |= 2; - break; - } - case "data": { - const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - data = descriptor.value; - seen |= 4; - break; - } - default: - return undefined; - } - } - if (seen !== 7) return undefined; + if (nativeReflectOwnKeys(value).length !== 3) return undefined; + + const messageId = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const type = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const data = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if (!messageId || !("value" in messageId) || !type || !("value" in type) || !data || !("value" in data)) { + return undefined; + } + + const messageType = type.value; + if ( + typeof messageId.value !== "string" || + (messageType !== "sendMessage" && + messageType !== "respMessage" && + messageType !== "connect" && + messageType !== "disconnect" && + messageType !== "connectMessage") + ) { + return undefined; } + + return { messageId: messageId.value, type: messageType, data: data.value } as WindowMessageBody; } catch { return undefined; } - if ( - typeof messageId !== "string" || - (type !== "sendMessage" && - type !== "respMessage" && - type !== "connect" && - type !== "disconnect" && - type !== "connectMessage") - ) { - return undefined; - } - return { messageId, type, data } as WindowMessageBody; }; export class WindowMessage implements Message { From 2d8a75a7af1c0cfdb6b324a8a502367ca9c7fa92 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:02:42 +0900 Subject: [PATCH 101/106] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20reuse=20PageMessag?= =?UTF-8?q?e=20connection=20sender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/page_message.test.ts | 31 +++++++++++++++++++++++++++ packages/message/page_message.ts | 21 +++++++++--------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/message/page_message.test.ts b/packages/message/page_message.test.ts index 39ea01396..0479243e8 100644 --- a/packages/message/page_message.test.ts +++ b/packages/message/page_message.test.ts @@ -124,4 +124,35 @@ describe("PageMessage", () => { expect(received).not.toHaveBeenCalled(); inject.dispose(); }); + + it("keeps page connections working when the page patches Function.prototype.bind", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const originalBind = Function.prototype.bind; + let connection: Awaited> | undefined; + + try { + let connectionPromise: ReturnType | undefined; + try { + Function.prototype.bind = (() => { + throw new Error("patched bind"); + }) as typeof Function.prototype.bind; + connectionPromise = scripting.connect({ action: "inject/connect" }); + } finally { + Function.prototype.bind = originalBind; + } + + connection = await connectionPromise!; + connection.sendMessage({ action: "inject/message" }); + + expect(target.postMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "connectMessage", data: { action: "inject/message" } }), + "*" + ); + } finally { + connection?.disconnect(true); + scripting.dispose(); + } + }); }); diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts index 9fe9b73d7..011f9cbcd 100644 --- a/packages/message/page_message.ts +++ b/packages/message/page_message.ts @@ -169,6 +169,9 @@ export class PageMessage implements Message { private readonly postMessage: (message: unknown, targetOrigin: string) => void; private readonly messageHandler: (event: MessageEvent) => void; private readonly targetRole: PageMessageRole; + private sendEnvelopeBound: + | ((target: PageMessageRole, body: Omit) => void) + | undefined; constructor( private readonly channel: string, @@ -181,16 +184,10 @@ export class PageMessage implements Message { this.messageHandler = (event: MessageEvent) => { if (event.source !== null && event.source !== sourceWindow) return; const body = parsePageMessageBody(event.data); - if ( - !body || - body.channel !== this.channel || - body.target !== this.role || - body.source !== this.targetRole || - typeof body.messageId !== "string" - ) { + if (!body || body.channel !== this.channel || body.target !== this.role || body.source !== this.targetRole) { return; } - this.messageHandle(body as PageMessageBody); + this.messageHandle(body); }; sourceWindow.addEventListener("message", this.messageHandler); } @@ -207,6 +204,10 @@ export class PageMessage implements Message { ); } + private getSendEnvelopeBound() { + return (this.sendEnvelopeBound ??= bindNative(this.sendEnvelope, this)); + } + private messageHandle(body: PageMessageBody): void { if (body.type === "sendMessage") { this.EE.emit( @@ -227,7 +228,7 @@ export class PageMessage implements Message { this.EE.emit( "connect", body.data, - new PageMessageConnect(body.messageId, body.source, this.sendEnvelope.bind(this), this.EE) + new PageMessageConnect(body.messageId, body.source, this.getSendEnvelopeBound(), this.EE) ); } else if (body.type === "disconnect") { this.EE.emit(`disconnect:${body.messageId}`); @@ -247,7 +248,7 @@ export class PageMessage implements Message { connect(data: TMessage): Promise { const messageId = uuidv4(); this.sendEnvelope(this.targetRole, { messageId, type: "connect", data }); - return Promise.resolve(new PageMessageConnect(messageId, this.targetRole, this.sendEnvelope.bind(this), this.EE)); + return Promise.resolve(new PageMessageConnect(messageId, this.targetRole, this.getSendEnvelopeBound(), this.EE)); } sendMessage(data: TMessage): Promise { From 5b07c7e00321e54ae77c2230971e57b230ffe883 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:31:35 +0900 Subject: [PATCH 102/106] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20structuredClone=20=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/global.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 9b2392227..12c16d2e3 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -1,15 +1,10 @@ // 避免在全局页面环境中,内置处理函数被篡改或重写 -const unsupportedAPI = () => { - throw "unsupportedAPI"; -}; // 在页面或用户脚本替换调用内建函数前完成捕获。 export const nativeReflectApply = Reflect.apply; const nativeFunctionBind = Function.prototype.bind; -const hasNativeStructuredClone = typeof structuredClone === "function"; -const nativeStructuredClone = hasNativeStructuredClone - ? nativeReflectApply(nativeFunctionBind, structuredClone, [globalThis]) - : unsupportedAPI; +// structuredClone 不用 bind globalThis; nativeStructuredClone 在初期化時捕获。 +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; const nativeSetConstructor = Set; const nativeSetAdd = Set.prototype.add; const nativeSetHas = Set.prototype.has; @@ -177,10 +172,10 @@ export const customClone = (o: any) => { }; if (!isDataOnly(o)) return undefined; - if (hasNativeStructuredClone) { + if (nativeStructuredClone) { try { // 优先使用 structuredClone,支持大多数可克隆对象 - return Native.structuredClone(o); + return nativeStructuredClone(o); } catch { // structuredClone 拒绝的值不再退回会执行 getter 的 JSON 序列化。 return undefined; From dcd220cf699722ddf2b75b84664cce6b30bc99c5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:32:29 +0900 Subject: [PATCH 103/106] =?UTF-8?q?=F0=9F=93=9A=20=E6=9B=B4=E6=96=B0=20Age?= =?UTF-8?q?nt=20=E4=B8=8E=E6=B6=88=E6=81=AF=E4=BC=A0=E8=BE=93=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 17 ++++++++++--- docs/architecture.md | 19 +++++++++++--- docs/references/architecture-agent.md | 31 ++++++++++++++++++++++- docs/references/architecture-execution.md | 9 +++++-- docs/references/architecture-gm-api.md | 4 ++- packages/message/README.md | 3 +++ 6 files changed, 72 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c46b70f4b..005dca2ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,13 @@ Service Worker (src/service_worker.ts) > SW → Offscreen uses `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage`) on Chrome and > `EventPageOffscreenManager` on Firefox MV3; Offscreen replies to SW over `ExtensionMessage`. `WindowMessage` > is the Offscreen ↔ Sandbox channel. +> +> USER_SCRIPT content and MAIN inject runtimes normally use native extension channels directly to the SW. +> The `scripting` bundle is a document-start extension content script registered per matching frame. It runs a +> page-bridge runtime and is a supporting per-document helper rather than a separate service/background context in +> this five-context model. Those bridges carry the content bootstrap handoff, MAIN bootstrap/fallback and runtime update packets, +> synchronous DOM handles, and the whitelisted `external.Scriptcat` API. When MAIN GM RPC falls back through +> `PageMessage`, the scripting runtime validates its execution handle and grant before forwarding it to the SW. - **Service Worker** — central hub for script CRUD, Chrome APIs, permission verification, resource caching, and message routing. - **Content** — bridges SW and inject script. @@ -244,9 +251,13 @@ Sandbox. ### Message Passing (`packages/message/`) -`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `WindowMessage` (postMessage — Offscreen ↔ -Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage` — SW → Offscreen on Chrome), -`CustomEventMessage` (CustomEvent — Content ↔ Inject), and `MessageQueue` (cross-context broadcast). +`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `PageMessage` (`window.postMessage` — +scripting ↔ Inject page bridge, including validated MAIN RPC fallback), `CustomEventMessage` (CustomEvent — +bootstrap handoff and DOM handles), +`WindowMessage` (`postMessage` — Offscreen ↔ Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + +`postMessage` — SW → Offscreen on Chrome), and `MessageQueue` (cross-context broadcast). Page-visible bridges do +not establish an authenticated extension origin; MAIN requests relayed through `PageMessage` must pass the +`PageRpcRegistry` checks before forwarding. ### Service & Data Layers diff --git a/docs/architecture.md b/docs/architecture.md index 3b33262f8..58808bc79 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,6 +77,16 @@ Three ideas explain almost everything in the codebase: inject, and sandbox don't hold a MessageQueue instance. ``` +The diagram compresses the page-facing routes: USER_SCRIPT content and MAIN inject runtimes also connect directly +to the Service Worker through native extension channels on the preferred path. The `scripting` bundle is a +document-start extension content script registered per matching frame; it runs a page-bridge runtime and is a +supporting per-document helper rather than a separate service/background context in this five-context model. +`CustomEventMessage` carries the content bootstrap +handoff and synchronous DOM handles; `PageMessage` carries MAIN bootstrap/fallback traffic, runtime event/value +updates, and the whitelisted `external.Scriptcat` API. When MAIN GM RPC uses the page bridge fallback, the scripting +runtime validates the execution handle and grant before forwarding it to the Service Worker; the page bridge +itself is not an authenticated extension origin. + --- ## The Five Contexts (Process Model) @@ -86,8 +96,8 @@ Each context is a separate bundle (see [Build pipeline & manifest](./references/ | Context | Entry | Realm / capabilities | Bootstraps | |---|---|---|---| | **Service Worker** | [`src/service_worker.ts`](../src/service_worker.ts) | No DOM. Owns `chrome.*` privileged APIs, storage, permissions, routing. | `ExtensionMessage(true)` → `Server("serviceWorker")` + `MessageQueue` → `ServiceWorkerManager` | -| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Uses a native extension channel for bootstrap, GM RPC, value updates, and callbacks; dedicated USER_SCRIPT listeners are preferred, with a token-bound regular port fallback when they cannot be registered. Retains a narrow DOM channel for synchronous node helpers. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` only for DOM handles | -| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `CustomEventMessage` to content + `Server("inject")` | +| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Receives a document bootstrap token through the page-side bridge, then uses a native extension channel for script loading, GM RPC, value updates, and callbacks. Dedicated USER_SCRIPT listeners are used when available; otherwise the regular port is token-bound. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` for bootstrap handoff and DOM handles | +| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | Native extension port for the preferred GM RPC path; `PageMessage` for bootstrap/fallback, whitelisted external API, and validated GM RPC fallback; `CustomEventMessage` for synchronous DOM handles | | **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | `ExtensionMessage()` + `WindowMessage(window, sandbox)` → `OffscreenManager` | | **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen. Evaluates background/scheduled scripts; runs cron. | `WindowMessage(window, parent)` + `Server("sandbox")` → `SandboxManager` | @@ -167,8 +177,9 @@ communication styles** over **several transports**. | Class | File | Connects | Underlying API | |---|---|---|---| -| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox; token-bound regular-port fallback when dedicated listeners are unavailable) | -| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ Inject | DOM `CustomEvent` dispatch (bypasses page tampering) | +| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect`; browser-identified USER_SCRIPT messages are action-gated, and regular-port fallbacks are token-bound | +| `PageMessage` | [`page_message.ts`](../packages/message/page_message.ts) | `scripting` ↔ Inject | `window.postMessage`; page-visible MAIN bootstrap/fallback, runtime updates, whitelisted external API, and GM RPC fallback validated by `PageRpcRegistry` | +| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ `scripting` page helper | DOM `CustomEvent`; bootstrap handoff and synchronous DOM references, not privileged GM RPC | | `WindowMessage` | [`window_message.ts`](../packages/message/window_message.ts) | Offscreen ↔ Sandbox | `window.postMessage` | | `ServiceWorkerMessageSend` | [`window_message.ts`](../packages/message/window_message.ts) | SW → Offscreen (Chrome) | `clients.matchAll()` + `postMessage` | | `MessageQueue` | [`message_queue.ts`](../packages/message/message_queue.ts) | Broadcast among the contexts that instantiate it — SW, Offscreen, UI pages | `chrome.runtime.sendMessage` + local `EventEmitter3` | diff --git a/docs/references/architecture-agent.md b/docs/references/architecture-agent.md index 8defc00b4..f46812a0b 100644 --- a/docs/references/architecture-agent.md +++ b/docs/references/architecture-agent.md @@ -98,6 +98,34 @@ The Agent subsystem does not use one persistence pattern; pick by data shape, ma attachments), `AgentTaskRunRepo` (task run history), `SkillRepo` (skill `.md`/script bundles). - `MCPServerRepo` (`Repo`) — MCP server configs. +## Userscript resource ownership + +The `CAT.agent.*` APIs are granted per script, but a grant alone does not decide which persisted resources that +script can access. The service-worker GM handlers take the caller identity from `request.script.uuid` and pass it +to the Agent services; they do not use a caller-supplied `scriptUuid` as the authority. + +- **Conversations** created by a script persist `ownerScriptUuid`. Script reads, chats, attaches, and mutations + check that owner. UI and legacy conversations without an owner remain available to the extension UI but are not + visible to script callers. Ephemeral chats are not persisted conversations. +- **Tasks** created by a script persist `ownerScriptUuid`; script list/get/update/delete/enable/run/history + operations are scoped to that owner. For compatibility, a legacy event task without an owner remains visible + only to the script named by `sourceScriptUuid`. +- **DOM monitors** are scoped to the script UUID supplied by the service-worker GM handler and to the tab. A + script caller cannot peek, stop, or replace a monitor owned by another script. +- **Attachments** live in the shared OPFS workspace and do not carry owner metadata themselves. Before + `CAT.agent.opfs.readAttachment` returns a file, `AgentChatRepo` verifies that a persisted message references + it from a conversation owned by the calling script. A guessed ID or a reference borrowed from another script's + conversation is insufficient. + +The checks are implemented in [`gm_agent.ts`](../../src/app/service/service_worker/gm_api/gm_agent.ts), +[`gm_agent_dom.ts`](../../src/app/service/service_worker/gm_api/gm_agent_dom.ts), +[`gm_agent_task.ts`](../../src/app/service/service_worker/gm_api/gm_agent_task.ts), +[`chat_service.ts`](../../src/app/service/agent/service_worker/chat_service.ts), +[`task_service.ts`](../../src/app/service/agent/service_worker/task_service.ts), +[`background_session_manager.ts`](../../src/app/service/agent/service_worker/background_session_manager.ts), +[`opfs_service.ts`](../../src/app/service/agent/service_worker/opfs_service.ts), and +[`dom_cdp.ts`](../../src/app/service/agent/service_worker/dom_cdp.ts). + ## Page / offscreen / sandbox delegation and permission boundaries - **Content (`src/app/service/content/gm_api/cat_agent.ts`)** exposes the `CAT.agent.*` API to user scripts — @@ -120,7 +148,8 @@ The Agent subsystem does not use one persistence pattern; pick by data shape, ma uses CDP; a background (non-active) tab tries CDP first and falls back to `chrome.tabs.captureVisibleTab` on failure; an active tab with no selector uses `chrome.tabs.captureVisibleTab` directly. - **Tab monitoring** (`startMonitor`/`stopMonitor`/`peekMonitor`) is unconditionally CDP-based — there is no - non-CDP path for it at all. + non-CDP path for it at all. A monitor is scoped to its tab and initiating script; other scripts cannot + inspect, stop, or replace it. CDP attaches the debugger to a tab and carries the extra permission/user-visible-banner implications that come with `chrome.debugger`; how often that applies depends on which action you're looking at, not a single diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index ed4d51fee..c5c054a30 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -39,8 +39,13 @@ patterns and registers the compiled payload (the `scripting` bundle) with `chrom ([`script_runtime.ts`](../../src/app/service/content/script_runtime.ts), [`exec_script.ts`](../../src/app/service/content/exec_script.ts)) evaluates the compiled function with the GM context. The `USER_SCRIPT` content path obtains its matched scripts directly from the service worker over -`ExtensionMessage`; the isolated `scripting` bundle keeps the page-observable event bridge for `MAIN` execution and -the synchronous DOM helper only. +`ExtensionMessage` after a bootstrap-token handoff. The MAIN `inject` path uses a native extension port for GM RPC +when available; `PageMessage` carries page-visible bootstrap/fallback traffic, MAIN event/value updates, the +whitelisted `external.Scriptcat` API, and the MAIN GM RPC fallback through the `scripting` bundle. That fallback +is checked against the current `PageRpcRegistry` execution handle and grant before it is forwarded to the service +worker. `CustomEventMessage` carries the content bootstrap handoff and synchronous DOM references. Neither +page-visible bridge establishes an authenticated extension origin, so consumers must validate its payloads before +acting on them. ### Path B — Background scripts → Offscreen → Sandbox diff --git a/docs/references/architecture-gm-api.md b/docs/references/architecture-gm-api.md index 1b83aad43..9850cdc57 100644 --- a/docs/references/architecture-gm-api.md +++ b/docs/references/architecture-gm-api.md @@ -85,4 +85,6 @@ traditional GM API: `@GMContext.API` on the content side [`compat-grant.js`](../../packages/eslint/compat-grant.js). What differs is the naming and transport shape — the grant is dotted (`CAT.agent.conversation`) and bound with `follow:` rather than `alias:`, the SW handlers set `dotAlias: false`, and conversation chat streams over `connect()` instead of `sendMessage`. Copy -the nearest existing `CAT.agent.*` method rather than a `GM_*` one. +the nearest existing `CAT.agent.*` method rather than a `GM_*` one. The service-worker handlers derive the script +identity from `request.script.uuid`, then the Agent services enforce persisted resource ownership; see +[`architecture-agent.md`](./architecture-agent.md#userscript-resource-ownership) for the scope and legacy rules. diff --git a/packages/message/README.md b/packages/message/README.md index 7e15272ea..a4206d28e 100644 --- a/packages/message/README.md +++ b/packages/message/README.md @@ -19,3 +19,6 @@ document),细节见 - service_worker 和 offscreen 之间可以使用 postMessage 的方式进行通信,避免同时监听 message 与 connect 导致冲突的问题。 - service_worker 会在空闲后进入不活动状态;与它建立的 `connect()` 长连接会在此时中断,因此需要长连接的场景要考虑 重连/状态恢复,而不是假定连接一直存活——这不是禁止在 service_worker 上使用 `connect`,只是需要为其生命周期设计容错。 +- USER_SCRIPT content 和 MAIN inject 优先使用 `ExtensionMessage` 原生扩展通道;服务端区分浏览器提供的 USER_SCRIPT 来源,并把 MAIN 或专用监听器不可用时的普通端口绑定到文档 bootstrap token。 +- `Server("serviceWorker")` 对浏览器标记的 `userScript` 来源仅允许 `connect()` 使用 `runtime/registerUserScript` 或 `runtime/gmApi`,仅允许 `sendMessage()` 使用 `runtime/gmApi` 或 `runtime/reconnectUserScript`;普通 extension 端口不带该来源标记,USER_SCRIPT / MAIN 的注册回退路径会在 `runtime/registerUserScript` 握手中校验文档 bootstrap token。 +- `CustomEventMessage` 和 `PageMessage` 是页面可见的桥:前者承载 content bootstrap 交接与同步 DOM 节点引用,后者承载 MAIN bootstrap/fallback、事件/值更新、白名单 `external.Scriptcat` API,以及经 `scripting` 中转的 GM RPC fallback。它们不提供已认证的扩展来源;`PageMessage` 的 GM RPC 在转发前必须通过 `PageRpcRegistry` 的执行句柄与 grant 校验。 From d9cb85cbf2e31787f584d2d1460aa5f4710a6f06 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:56:50 +0900 Subject: [PATCH 104/106] =?UTF-8?q?=F0=9F=93=84=20Pareto=20review=20tracke?= =?UTF-8?q?d=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 12 ++---------- CONTRIBUTING.md | 2 +- README.md | 14 ++++++-------- docs/CONTRIBUTING_RU.md | 2 +- docs/CONTRIBUTING_ZH.md | 2 +- docs/README_RU.md | 15 ++++++--------- docs/README_ja.md | 10 ++++------ docs/README_zh-CN.md | 6 +++--- docs/README_zh-TW.md | 10 ++++------ docs/architecture.md | 6 ++++-- docs/design.md | 19 ++++++++----------- docs/develop.md | 2 +- docs/pull-request.md | 11 ----------- docs/references/architecture-build.md | 8 ++++---- docs/references/architecture-data.md | 6 ++++-- docs/references/architecture-execution.md | 7 ++++--- docs/references/design-components.md | 2 +- docs/references/design-patterns.md | 4 ++-- docs/references/design-tokens.md | 3 ++- docs/references/develop-testing.md | 11 +---------- docs/references/terminology-ko-KR.md | 4 ++-- example/tests/lib/README.md | 2 +- packages/chrome-extension-mock/README.md | 4 +++- packages/message/README.md | 5 +++-- 24 files changed, 68 insertions(+), 99 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 005dca2ba..f87214529 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,9 +40,9 @@ don't bulk-load `.deepwiki/`. Treat it as background only: current code and the ## Project Overview -ScriptCat is a Manifest V3 browser extension for Tampermonkey-compatible user scripts, built with TypeScript, +ScriptCat is a Manifest V3 browser extension for userscripts inspired by Tampermonkey, built with TypeScript, React 19, and Rspack. **pnpm** is required by `preinstall`. The presentation layer (`src/pages/`) uses shadcn/ui -and Tailwind CSS v4 (migrated from Arco Design + UnoCSS). +and Tailwind CSS v4. ## Engineering Principles @@ -68,14 +68,6 @@ downstream prose does not override it. establish a root-cause fix; report the trigger, evidence, and remaining uncertainty. Follow the asynchronous observation and timing guidance in [`docs/references/develop-testing.md`](docs/references/develop-testing.md#observation-rules-for-asynchronous-tests). -- **Shared E2E helpers must model both outcomes.** A helper that drives a save, install, or other mutation must make - the expected success or failure explicit and wait for that operation's matching signal. Negative cases must opt into - the failure contract; never make them pass by accepting an arbitrary toast, an old notification, or a page shell. -- **Performance-sensitive UI fixtures must stay bounded.** Use the smallest synthetic fixture that crosses the - required boundary; for filtering or pagination, do not eagerly render unrelated rows before the trigger. Obvious - explicit one-page-plus fixtures need a line-level `scriptcat/no-test-large-boundary-fixture` rationale; do not hide - their cost by raising the test timeout. The detailed fixture and measurement rules live in - [`docs/references/develop-testing.md`](docs/references/develop-testing.md#vitest-performance-hygiene). - **Dnd-kit list rendering must keep the drag boundary cheap.** Keep sensor options, modifiers, callbacks, and the sortable item-list reference stable when their values are unchanged; render plain rows/cards while dragging is disabled instead of mounting `DndContext`/`SortableContext`. Stabilize item identity with a collision-safe diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 298356a1e..eee700113 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,7 @@ If you want to run ScriptCat locally, you can use the following commands: ```bash pnpm run dev -# Please note that for unknown reasons, if you need to use incognito windows, you need to use the following command for development +# Development build without source maps pnpm run dev:noMap ``` diff --git a/README.md b/README.md index 4774f0573..469385724 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,9 @@ ScriptCat ## About ScriptCat -ScriptCat is a powerful userscript manager based on Tampermonkey's design philosophy, fully compatible with Tampermonkey -scripts. It not only supports traditional userscripts but also innovatively implements a background script execution -framework with rich API extensions, enabling scripts to accomplish more powerful functions. It features an excellent -built-in code editor with intelligent completion and syntax checking, making script development more efficient and -smooth. +ScriptCat is a userscript manager inspired by Tampermonkey. It adds a background script execution framework with +extended APIs. Its built-in code editor provides intelligent completion and syntax checking to make script development +more efficient. **If you find it useful, please give us a Star ⭐ This is the greatest support for us!** @@ -43,7 +41,7 @@ smooth. ### 🔧 Powerful Functions -- **Full Tampermonkey Compatibility**: Seamlessly migrate existing Tampermonkey scripts with zero learning curve +- **Tampermonkey Script Support**: Compatibility depends on a script's APIs and metadata; some may need adjustments - **Background Scripts**: Innovative background execution mechanism, keeping scripts running continuously without page limitations - **Scheduled Scripts**: Support timed execution tasks for auto check-ins, scheduled reminders, and more @@ -87,8 +85,8 @@ If you cannot access extension stores, download the latest ZIP package from 1. **Get from Script Markets**: Visit [ScriptCat Script Store](https://scriptcat.org/en/search) or other userscript markets 2. **Background Scripts Zone**: Experience unique [Background Scripts](https://scriptcat.org/en/search?script_type=3) -3. **Compatibility**: Supports most Tampermonkey scripts, can be installed directly. If you encounter incompatible - scripts, please report them to us through [issues](https://github.com/scriptscat/scriptcat/issues). +3. **Compatibility**: Compatibility depends on each script's APIs and metadata. If a script does not run, report it + through [issues](https://github.com/scriptscat/scriptcat/issues). #### Developing Scripts diff --git a/docs/CONTRIBUTING_RU.md b/docs/CONTRIBUTING_RU.md index fb5fcefc9..f983378fc 100644 --- a/docs/CONTRIBUTING_RU.md +++ b/docs/CONTRIBUTING_RU.md @@ -94,7 +94,7 @@ pnpm run lint ```bash pnpm run dev -# Обратите внимание: по неизвестным причинам, если вам нужно использовать режим инкогнито, используйте следующую команду для разработки +# Сборка для разработки без source maps pnpm run dev:noMap ``` diff --git a/docs/CONTRIBUTING_ZH.md b/docs/CONTRIBUTING_ZH.md index 201ec8efd..9129854c8 100644 --- a/docs/CONTRIBUTING_ZH.md +++ b/docs/CONTRIBUTING_ZH.md @@ -96,7 +96,7 @@ ScriptCat 的页面开发使用了以下技术: ```bash pnpm run dev -# 请注意,由于未知原因,如果你需要使用隐身窗口,你需要使用下面的命令进行开发 +# 不生成 source map 的开发构建 pnpm run dev:noMap ``` diff --git a/docs/README_RU.md b/docs/README_RU.md index 02a159236..31c4155d1 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -22,11 +22,9 @@ ## О проекте -ScriptCat — это мощный менеджер пользовательских скриптов, основанный на философии Tampermonkey и полностью совместимый -с его скриптами. Он не только поддерживает традиционные пользовательские скрипты, но и инновационно реализует фреймворк -для выполнения фоновых скриптов, предоставляет богатый API для расширений, позволяя скриптам выполнять более мощные -функции. Встроенный превосходный редактор кода с поддержкой интеллектуального дополнения и проверки синтаксиса делает -разработку скриптов более эффективной и плавной. **Если вам понравилось, пожалуйста, поставьте нам звезду (Star) ⭐ — +ScriptCat — это менеджер пользовательских скриптов, вдохновлённый Tampermonkey. Он также предоставляет фреймворк +фоновых скриптов с расширенным API. Встроенный редактор кода с автодополнением и проверкой синтаксиса упрощает +разработку скриптов. **Если вам понравилось, пожалуйста, поставьте нам звезду (Star) ⭐ — это лучшая поддержка для нас!** ## ✨ Ключевые особенности @@ -40,8 +38,8 @@ ScriptCat — это мощный менеджер пользовательск ### 🔧 Мощный функционал -- **Полная совместимость с Tampermonkey**: Бесшовная миграция существующих скриптов Tampermonkey, нулевая кривая - обучения. +- **Совместимость со скриптами Tampermonkey**: Она зависит от API и метаданных скрипта; некоторым может потребоваться + доработка. - **Фоновые скрипты**: Уникальный механизм фонового выполнения позволяет скриптам работать непрерывно без ограничений со стороны страницы. - **Скрипты по расписанию**: Поддержка выполнения задач по расписанию для реализации автоматического подтверждения @@ -87,8 +85,7 @@ ScriptCat — это мощный менеджер пользовательск другие маркетплейсы пользовательских скриптов. 2. **Раздел фоновых скриптов**: Ознакомьтесь с уникальными [фоновыми скриптами](https://scriptcat.org/ru/search?script_type=3). -3. **Совместимость**: Поддерживается подавляющее большинство скриптов для Tampermonkey, их можно устанавливать и - использовать напрямую. Если вы столкнетесь с несовместимым скриптом, пожалуйста, сообщите нам через +3. **Совместимость**: Она зависит от API и метаданных скрипта. Если скрипт не работает, сообщите нам через [issue](https://github.com/scriptscat/scriptcat/issues). #### Разработка скриптов diff --git a/docs/README_ja.md b/docs/README_ja.md index 7ae46ba03..18ada28a2 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -25,9 +25,8 @@ ScriptCat ## ScriptCat について -ScriptCat は、Tampermonkey の設計思想に基づく強力なユーザースクリプトマネージャーで、Tampermonkey のスクリプトと完全な互換性を持ちます。 -従来のユーザースクリプトをサポートするだけでなく、豊富な API 拡張を備えたバックグラウンドスクリプト実行フレームワークを革新的に実装し、スクリプトでより強力な機能を実現できます。 -また、優れた内蔵コードエディタを搭載し、インテリジェント補完や構文チェックに対応しており、スクリプト開発をより効率的かつスムーズに行えます。 +ScriptCat は Tampermonkey の設計思想を参考にしたユーザースクリプトマネージャーです。 +拡張 API を備えたバックグラウンドスクリプト実行機能も提供します。内蔵コードエディタは補完と構文チェックに対応し、スクリプト開発を効率化します。 **便利だと感じたら、ぜひ Star ⭐ を付けて応援してください!** @@ -40,7 +39,7 @@ ScriptCat は、Tampermonkey の設計思想に基づく強力なユーザース ### 🔧 強力な機能 -- **Tampermonkey と完全互換**:既存の Tampermonkey スクリプトを学習コストなしでそのまま移行可能 +- **Tampermonkey スクリプトへの対応**:互換性はスクリプトが使う API やメタデータによって異なり、修正が必要な場合があります - **バックグラウンドスクリプト**:ページに依存せず連続実行できる革新的なバックグラウンド実行機構 - **スケジュールスクリプト**:自動チェックイン、リマインダーなどの定時実行をサポート - **豊富な API**:Tampermonkey 以上の強力な API 群を提供 @@ -81,8 +80,7 @@ ScriptCat は、Tampermonkey の設計思想に基づく強力なユーザース 1. **スクリプトセンターから取得**: [ScriptCat スクリプトセンター](https://scriptcat.org/ja/search) またはその他のユーザースクリプトセンターへアクセス 2. **バックグラウンドスクリプトセンター**:ユニークな [バックグラウンドスクリプト](https://scriptcat.org/ja/search?script_type=3) を体験 -3. **互換性**:多くの Tampermonkey スクリプトをサポートしており、そのままインストール可能。不具合があれば - [issues](https://github.com/scriptscat/scriptcat/issues) にてご報告ください。 +3. **互換性**:スクリプトによって対応状況は異なります。動作しない場合は [issues](https://github.com/scriptscat/scriptcat/issues) にてご報告ください。 #### スクリプト開発 diff --git a/docs/README_zh-CN.md b/docs/README_zh-CN.md index 241c6beb3..e6d753dbc 100644 --- a/docs/README_zh-CN.md +++ b/docs/README_zh-CN.md @@ -25,7 +25,7 @@ ScriptCat ## 关于 -ScriptCat(脚本猫)是一个功能强大的用户脚本管理器,基于油猴的设计理念,完全兼容油猴脚本。它不仅支持传统的用户脚本,还创新性地实现了后台脚本运行框架,提供丰富的API扩展,让脚本能够完成更多强大的功能。内置优秀的代码编辑器,支持智能补全和语法检查,让脚本开发更加高效流畅。 +ScriptCat(脚本猫)是一个参考油猴设计理念的用户脚本管理器,也提供具备扩展 API 的后台脚本运行框架。内置代码编辑器支持智能补全和语法检查,帮助提高脚本开发效率。 **如果觉得好用,请给我们一个 Star ⭐ 这是对我们最大的支持!** @@ -38,7 +38,7 @@ ScriptCat(脚本猫)是一个功能强大的用户脚本管理器,基于 ### 🔧 强大功能 -- **完全兼容油猴**:无缝迁移现有油猴脚本,零学习成本 +- **油猴脚本兼容性**:兼容情况取决于脚本使用的 API 和元数据,部分脚本可能需要调整 - **后台脚本**:独创后台运行机制,让脚本持续运行不受页面限制 - **定时脚本**:支持定时执行任务,实现自动签到、定时提醒等功能 - **丰富 API**:相比油猴提供更多强大 API,解锁更多可能性 @@ -79,7 +79,7 @@ ScriptCat(脚本猫)是一个功能强大的用户脚本管理器,基于 1. **从脚本市场获取**:访问 [ScriptCat 脚本站](https://scriptcat.org/search) 或其他用户脚本市场 2. **后台脚本专区**:体验独有的 [后台脚本](https://scriptcat.org/zh-CN/search?script_type=3) -3. **兼容性**:支持绝大部分油猴脚本,可直接安装使用,如果遇到不兼容的脚本,欢迎通过 +3. **兼容性**:兼容情况取决于脚本使用的 API 和元数据。如果脚本无法运行,欢迎通过 [issue](https://github.com/scriptscat/scriptcat/issues) 反馈给我们。 #### 开发脚本 diff --git a/docs/README_zh-TW.md b/docs/README_zh-TW.md index 0cf8ec4cb..1ca4be3ad 100644 --- a/docs/README_zh-TW.md +++ b/docs/README_zh-TW.md @@ -25,9 +25,8 @@ ScriptCat ## 關於 ScriptCat -ScriptCat 是一款基於 Tampermonkey 設計理念的強大使用者腳本管理器,完全相容 Tampermonkey 腳本。 -它不僅支援傳統使用者腳本,還創新實作了背景腳本執行框架,並擁有豐富的 API 擴充能力,使腳本能完成更強大的功能。 -內建優秀的程式碼編輯器,具備智慧補全與語法檢查,讓腳本開發更加高效與順暢。 +ScriptCat 是一款參考 Tampermonkey 設計理念的使用者腳本管理器,也提供具備擴充 API 的背景腳本執行框架。 +內建程式碼編輯器支援智慧補全與語法檢查,讓腳本開發更有效率。 **如果你覺得 ScriptCat 很有用,歡迎幫我們點一顆 Star ⭐ 這是對我們最好的支持!** @@ -40,7 +39,7 @@ ScriptCat 是一款基於 Tampermonkey 設計理念的強大使用者腳本管 ### 🔧 強大功能 -- **完整 Tampermonkey 相容性**:可無縫遷移現有 Tampermonkey 腳本,零學習成本 +- **Tampermonkey 腳本支援**:相容性取決於腳本使用的 API 和中繼資料,部分腳本可能需要調整 - **背景腳本**:創新的背景執行機制,使腳本可持續運作,不受頁面限制 - **排程腳本**:支援定時執行的任務,如自動簽到、定時提醒等 - **豐富 API**:提供比 Tampermonkey 更強大的 API,解鎖更多可能性 @@ -81,8 +80,7 @@ ScriptCat 是一款基於 Tampermonkey 設計理念的強大使用者腳本管 1. **從腳本市場取得**:前往 [ScriptCat 腳本站](https://scriptcat.org/zh-TW/search) 或其他使用者腳本市場 2. **背景腳本區**:體驗獨特的 [背景腳本](https://scriptcat.org/zh-TW/search?script_type=3) -3. **相容性**:支援多數 Tampermonkey 腳本,可直接安裝。若遇到不相容腳本,歡迎至 - [issues](https://github.com/scriptscat/scriptcat/issues) 回報給我們。 +3. **相容性**:相容性取決於腳本使用的 API 和中繼資料。若腳本無法執行,歡迎至 [issues](https://github.com/scriptscat/scriptcat/issues) 回報給我們。 #### 開發腳本 diff --git a/docs/architecture.md b/docs/architecture.md index 58808bc79..939835c1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,8 +101,10 @@ Each context is a separate bundle (see [Build pipeline & manifest](./references/ | **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | `ExtensionMessage()` + `WindowMessage(window, sandbox)` → `OffscreenManager` | | **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen. Evaluates background/scheduled scripts; runs cron. | `WindowMessage(window, parent)` + `Server("sandbox")` → `SandboxManager` | -There is also a sixth bundle, [`src/scripting.ts`](../src/scripting.ts), injected via `chrome.userScripts` / -`chrome.scripting` to carry the compiled page-script payload (see [Script execution](./references/architecture-execution.md)). +The [`scripting` bundle](../src/scripting.ts) is a document-start content script registered through +`chrome.scripting`; it supplies the per-document page bridge. Compiled userscript payloads and the `inject.js` / +`content.js` runners are registered separately through `chrome.userScripts` (see +[Script execution](./references/architecture-execution.md)). ### Service-worker bootstrap diff --git a/docs/design.md b/docs/design.md index ebd220e15..e299c29dd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -25,15 +25,12 @@ owned by [`develop.md` § UI](./develop.md#ui) — linked from here, never resta Every UI change must satisfy all of these. They are the bar for "friendly, consistent UI/UX" in this codebase. -- **Use tokens, not literal colors — one value, one place.** Never write a hex (`#1296db`), an `rgb()`, or a palette class (`text-blue-500`). Always use a semantic token — `bg-background`, `text-foreground`, `border-border`, `text-primary`, `bg-primary-background`, `text-muted-foreground`, … ([tokens](./references/design-tokens.md)). All color values live in exactly one place — the token definitions in `src/index.css` — so the palette stays unified and a single edit re-skins everything. One semantic concept maps to **one** token: before adding a color, check [tokens](./references/design-tokens.md) for an existing token and reuse it; don't introduce a near-duplicate (a second slightly-different gray or blue). Only add a new token when the concept is genuinely new — with both light and dark values — and document it in [tokens](./references/design-tokens.md). -- **Both themes, always.** Light and dark are first-class. Because every color comes from a token that has a `:root` and a `.dark` value, using tokens makes a component theme-correct for free. Verify on real light *and* dark before considering anything done ([theming](#theming)). +- **Both themes, always.** Verify the rendered change in light and dark before considering the UI work done ([theming](#theming)). - **Design for mobile too.** The UI is responsive around a single `768px` breakpoint (`useIsMobile`). Mobile is **a different shell, not a shrunk desktop** — side nav becomes bottom tabs + drawer, tables become cards, rows stack, details/code collapse, actions move into a sticky bar ([layout & responsive](./references/design-patterns.md#layout--responsive)). A feature isn't finished until it works on a narrow viewport. -- **No inline `style={{}}` for what Tailwind can express.** Compose utility classes via `cn()` (`clsx` + `tailwind-merge`); build variants with `class-variance-authority` (CVA). Inline styles only for genuinely dynamic values (e.g. a computed width). -- **Hover/focus are CSS, not state.** Express interactive visuals with pseudo-classes (`hover:bg-primary-background/90`, `focus-visible:ring-ring/50`). React state is for data/logic, not styling. -- **Reuse components before building new ones.** Default to the shadcn primitives in `src/pages/components/ui/` ([components](./references/design-components.md)); icons come from `lucide-react` only — don't hand-roll a control that already exists. Beyond primitives, search the existing pages for a composed block (card row, identity header, permission card, state screen…) that already does what you need and reuse it. When the same block appears in two or more places, extract one shared component instead of copy-pasting — keep one implementation per concept so behavior and styling stay consistent and a fix lands everywhere at once. +- **Reuse components before building new ones.** Default to the shadcn primitives in `src/pages/components/ui/` ([components](./references/design-components.md)). Beyond primitives, search the existing pages for a composed block (card row, identity header, permission card, state screen…) that already does what you need and reuse it. When the same block appears in two or more places, extract one shared component instead of copy-pasting — keep one implementation per concept so behavior and styling stay consistent and a fix lands everywhere at once. - **Keep motion restrained.** Enter/leave in `150–250ms`, `ease-out`; reuse the existing `@utility` animations rather than inlining `@keyframes`; prefer `transition-colors` over `transition-all` ([motion](./references/design-patterns.md#motion)). - **No silent operations.** Every async flow surfaces loading / empty / error / success (and progress for long-running work). The user must always know whether their action worked ([state patterns](./references/design-patterns.md#state-patterns)). -- **Don't introduce new colors or fonts ad hoc.** New color → add a token in `src/index.css` (with both light and dark values) and document it here. New font → add a `--font-*` token; don't reference an unconfigured family. +- **Don't introduce unconfigured fonts.** Add a `--font-*` token before using a new font family. --- @@ -96,7 +93,7 @@ setTheme("auto"); // "auto" follows the system theme and updates on change | `font-sans` (`--font-sans`) | `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"` | Body / UI text. Applied on `body` via `@apply font-sans`, so everything inherits it by default; this is the default — you rarely write `font-sans` explicitly | | `font-mono` (`--font-mono`) | `ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", Consolas, "Liberation Mono", "PingFang SC", "Microsoft YaHei", monospace` | Code, version numbers, `@match`/permission rules, stored values — anything monospaced (`font-mono`) | -> **No webfont, no `@font-face`.** Don't reference a family that isn't actually packaged (it would silently fall back and mislead — Constraint 9). If a brand font is genuinely required, self-host it (woff2, local `@font-face`, never a CDN), keep the CJK fallback, and update this table. +> **No webfont, no `@font-face`.** Don't reference a family that isn't actually packaged (it would silently fall back and mislead). If a brand font is genuinely required, self-host it (woff2, local `@font-face`, never a CDN), keep the CJK fallback, and update this table. ### Radius @@ -124,12 +121,12 @@ When building a new page or dialog, run this checklist to stay consistent: - [ ] **Entry** reuses the existing `main.tsx` pattern — mount `ThemeProvider`, `Toaster` (and `TooltipProvider` if needed); don't roll your own theme logic. - [ ] **Shell:** sticky TopBar + `.scrollbar-custom` scroll container + sticky ActionBar ([layout & responsive](./references/design-patterns.md#layout--responsive)). -- [ ] **Responsive:** branch on `useIsMobile()`; re-shell on mobile (bottom bar/drawer, cards, collapse) rather than scaling down (Constraint 3, [layout & responsive](./references/design-patterns.md#layout--responsive)). -- [ ] **Color** entirely from tokens (`bg-card` / `text-foreground` / `border-border` / `text-primary` / `bg-primary-background` …), no literals, verified on both themes (Constraint 1–2, [tokens](./references/design-tokens.md) & [theming](#theming)). -- [ ] **Components** reuse first — search existing pages for a composed block before building; use `src/pages/components/ui/` primitives; extract a shared component when a block repeats; variants via CVA, classes via `cn()`, icons via `lucide-react` (Constraint 6, [components](./references/design-components.md)). +- [ ] **Responsive:** branch on `useIsMobile()`; re-shell on mobile (bottom bar/drawer, cards, collapse) rather than scaling down ([layout & responsive](./references/design-patterns.md#layout--responsive)). +- [ ] **Color** entirely from tokens (`bg-card` / `text-foreground` / `border-border` / `text-primary` / `bg-primary-background` …), no literals, verified on both themes ([tokens](./references/design-tokens.md) & [theming](#theming)). +- [ ] **Components** reuse first — search existing pages for a composed block before building; use `src/pages/components/ui/` primitives; extract a shared component when a block repeats ([components](./references/design-components.md)). - [ ] **Hierarchy** orders the most important info first; decision pages go identity → permissions → code (Principle 1). - [ ] **State:** loading / empty / error / success / in-progress all covered, never silent ([state patterns](./references/design-patterns.md#state-patterns)). -- [ ] **Motion** restrained (`150–250ms`, `ease-out`), hover/focus via pseudo-classes, enter/leave via `data-state`, reuse existing utilities ([motion](./references/design-patterns.md#motion)). +- [ ] **Motion** restrained (`150–250ms`, `ease-out`), enter/leave via `data-state`, reuse existing utilities ([motion](./references/design-patterns.md#motion)). - [ ] **Depth** uses the elevation ladder (resting/raised/overlay, [elevation](./references/design-tokens.md#elevation-shadows)) and the z-index ladder (`z-10` chrome / `z-50` floating, [layering](./references/design-patterns.md#layering-z-index)) — no `shadow-2xl`, no magic `z-[…]`. - [ ] **Accessibility:** AA contrast on both themes; meaning never color-only; custom controls keyboard-reachable with a visible focus ring; `aria-label` on icon buttons; ≥ ~44px mobile tap targets; reduced-motion-safe ([accessibility](./references/design-patterns.md#accessibility)). - [ ] **Copy** defaults to sentence-case English + i18n; verbs on buttons; specific errors ([writing & microcopy](./references/design-patterns.md#writing--microcopy)), and flexes for long locales ([layout & responsive](./references/design-patterns.md#layout--responsive)); see [`develop.md`](./develop.md) and [`translation.md`](./translation.md). diff --git a/docs/develop.md b/docs/develop.md index a3dfaccf1..601d6ab01 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -11,7 +11,7 @@ ```bash pnpm install # install deps (preinstall enforces pnpm) pnpm run dev # dev build (source maps); load dist/ext as unpacked extension -pnpm run dev:noMap # dev build w/o source maps (incognito) +pnpm run dev:noMap # dev build w/o source maps pnpm run build # production Rspack build pnpm run pack # package the extension (requires dist/scriptcat.pem) diff --git a/docs/pull-request.md b/docs/pull-request.md index 2280e9662..354ec2023 100644 --- a/docs/pull-request.md +++ b/docs/pull-request.md @@ -132,17 +132,6 @@ Activate only the rows touched by the actual change; mixed changes use their uni | Persistence/migration/release | Compatibility and data scope, ordering/irreversibility, rollback/restore path, and rehearsal or invariant evidence where safe | | Async/concurrency/stateful UI | Duplicate in-flight work, stale or late results, cancellation/retry, cleanup, and identity or generation ordering where applicable | -## Review-oriented content - -For non-trivial changes, make the description useful for review: - -- `背景` explains the problem, compatibility gap, or maintenance need. -- `本次改动` summarizes user-visible behavior and important implementation changes. -- `实现考虑` records design decisions, invariants, lifecycle behavior, races, or compatibility choices. -- `已知限制` records unsupported cases, explicit scope boundaries, and follow-up work. -- `建议审查重点` lists concrete behaviors or risks reviewers should verify. -- `验证` lists exact commands and concise results, including known warnings or why a check was not run. - ## Documentation-only PRs For a PR that only changes Markdown, `验证` should reflect what a doc change actually needs, not an unrelated diff --git a/docs/references/architecture-build.md b/docs/references/architecture-build.md index 7d7cf3054..965bf1640 100644 --- a/docs/references/architecture-build.md +++ b/docs/references/architecture-build.md @@ -9,7 +9,7 @@ ``` context bundles : service_worker · offscreen · sandbox · content · inject · scripting shared : common (pre-React bootstrap, e.g. early theme init — see src/pages/common.ts) -UI pages (React): popup · options · install · batchupdate · confirm · import +UI pages (React): popup · options · install · batchupdate · confirm · external_access_confirm · import workers : editor.worker · ts.worker · json.worker (Monaco) · linter.worker ``` @@ -19,8 +19,8 @@ Output goes to `dist/ext/src/[name].js` (cleaned each build). Notable behavior: - **Path aliases** mirror `tsconfig.json`: `@App → src`, `@Packages → packages` (the `@Tests → tests` alias is test-only — defined in `vitest.config.ts` / `tsconfig.json`, not in the Rspack build). -- **Dev vs prod** via `NODE_ENV`: dev enables watch + inline source maps (skipped when `NO_MAP=true`, needed - for incognito); prod minifies with SWC + Lightning CSS and drops debug. +- **Dev vs prod** via `NODE_ENV`: dev enables watch + inline source maps (skipped when `NO_MAP=true`); prod minifies + with SWC + Lightning CSS and drops debug. - **Code splitting** pulls big libs into named `lib_*` chunks (react, monaco, radix-ui, dnd-kit, eslint, message), but **never splits** `service_worker`, `content`, `inject`, `scripting`, or the workers — MV3 requires those to be single self-contained files. @@ -78,7 +78,7 @@ MV3 officially supports Firefox, so `PACK_FIREFOX` is `true` by default and the | Package | Purpose | |---|---| | [`message`](../../packages/message) | The cross-context RPC + pub/sub layer (see [Message Passing](../architecture.md#message-passing)). Ships its own mocks. | -| [`filesystem`](../../packages/filesystem) | Pluggable FS adapters for sync/backup — WebDAV, cloud drives (OneDrive, Google Drive, Dropbox, Baidu, S3), and zip archives. | +| [`filesystem`](../../packages/filesystem) | Pluggable FS adapters for sync/backup; see the [package README](../../packages/filesystem/README.md) for providers and Zip behavior. | | [`cloudscript`](../../packages/cloudscript) | Cloud-script integration. | | [`eslint`](../../packages/eslint) | The ESLint config + globals shipped to the in-editor linter for userscripts (`CAT_*`, `GM_*`, `CATRetryError`, …). | | [`chrome-extension-mock`](../../packages/chrome-extension-mock) | A mock `chrome.*` + message bus for Vitest. | diff --git a/docs/references/architecture-data.md b/docs/references/architecture-data.md index a6534f85a..482074a70 100644 --- a/docs/references/architecture-data.md +++ b/docs/references/architecture-data.md @@ -40,8 +40,8 @@ Design notes: - **Cache:** `enableCache()` switches reads/writes to a process-local cache that mirrors storage — used for hot collections (scripts) to avoid repeated async reads. A subclass that overrides `joinKey` can hash keys (e.g. resources keyed by URL via a UUID-v5 namespace). -- **Storage errors are logged, not thrown** — `chrome.runtime.lastError` is checked and reads continue, since - a transient storage hiccup should not crash the worker. +- **Storage errors reject their promises.** The storage callback paths check `chrome.runtime.lastError` and reject; + `Repo` does not log the error and continue. ### Repository inventory @@ -57,6 +57,8 @@ Names ending in `DAO` don't all share one base class — check which backend bef | `PermissionDAO` | [`permission.ts`](../../src/app/repo/permission.ts) | `Permission` | Composite key `::` | | `SubscribeDAO` | [`subscribe.ts`](../../src/app/repo/subscribe.ts) | `Subscribe` | Keyed by feed URL | | `FaviconDAO`, `LocalStorageDAO`, `ExportDAO`, `TempStorageDAO` | `src/app/repo/*.ts` | misc | Same `Repo` pattern | +| `ExternalAccessOperationDAO` | [`external_access.ts`](../../src/app/repo/external_access.ts) | `ExternalAccessOperation` | External-access operation records | +| `NetworkRuleStateDAO` | [`network_rule.ts`](../../src/app/repo/network_rule.ts) | `NetworkRuleState` | Declarative network-rule state | | `AgentModelRepo` | [`agent_model.ts`](../../src/app/repo/agent_model.ts) | `AgentModelConfig` | Agent model configs — small, no indexed query need | | `AgentTaskRepo` | [`agent_task.ts`](../../src/app/repo/agent_task.ts) | `AgentTask` | Scheduled agent task definitions | | `MCPServerRepo` | [`mcp_server_repo.ts`](../../src/app/repo/mcp_server_repo.ts) | `MCPServerConfig` | MCP server configs | diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index c5c054a30..26f373f5a 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -33,9 +33,10 @@ Key points: ### Path A — Page scripts → `chrome.userScripts` -Normal userscripts run in the page. The SW builds a `RegisteredUserScript` from the script's `@match`/`@include` -patterns and registers the compiled payload (the `scripting` bundle) with `chrome.userScripts.register`, in the -`MAIN` or `USER_SCRIPT` world as required. At document time the content/inject pair +The SW compiles enabled userscripts and registers each payload through `chrome.userScripts` with its match, world, +and run-time settings. It also registers the `inject.js` and `content.js` runners there for the `MAIN` and +`USER_SCRIPT` paths. Separately, `scripting.js` is registered through `chrome.scripting` as a document-start +content script that supplies the page bridge. At document time the content/inject pair ([`script_runtime.ts`](../../src/app/service/content/script_runtime.ts), [`exec_script.ts`](../../src/app/service/content/exec_script.ts)) evaluates the compiled function with the GM context. The `USER_SCRIPT` content path obtains its matched scripts directly from the service worker over diff --git a/docs/references/design-components.md b/docs/references/design-components.md index 57416b3d9..372754d5e 100644 --- a/docs/references/design-components.md +++ b/docs/references/design-components.md @@ -2,7 +2,7 @@ ## Component palette & usage -The shadcn primitives live in [`src/pages/components/ui/`](../../src/pages/components/ui/) — `new-york` style, CSS variables enabled, no class prefix (`components.json`). Icons are always `lucide-react`; class merging is always `cn()` ([`src/pkg/utils/cn.ts`](../../src/pkg/utils/cn.ts)); variants are always CVA — these are the [`develop.md` § UI](../develop.md#ui) hard rules, not repeated here. This section is "what exists and how to choose." +The shadcn primitives live in [`src/pages/components/ui/`](../../src/pages/components/ui/) — `new-york` style, CSS variables enabled, no class prefix (`components.json`). Follow the [`develop.md` § UI](../develop.md#ui) for implementation rules; this section covers what exists and how to choose. ### Primitives & shared composites diff --git a/docs/references/design-patterns.md b/docs/references/design-patterns.md index de63e35e3..28dea9b35 100644 --- a/docs/references/design-patterns.md +++ b/docs/references/design-patterns.md @@ -147,7 +147,7 @@ A loading state is not one thing — and a centered spinner is the *last* resort Practical rules: -- **Never freeze and never wait silently.** A region that is loading must show a skeleton, spinner, or bar — never a blank or stale frame with no signal (Constraint 8). +- **Never freeze and never wait silently.** A region that is loading must show a skeleton, spinner, or bar — never a blank or stale frame with no signal ([Core Constraints](../design.md#core-constraints-non-negotiable)). - **Don't fake determinism.** Use the determinate progress bar only when the percent/bytes are actually known; otherwise use an indeterminate fill or a skeleton. - **One indicator per wait.** Don't stack a full-page spinner over content that is already skeletoned, or two bars for one fetch. - **The spinner is always `Loader2` + `animate-spin`** (`text-primary` when it should read as active), sized to context — `size-3.5`/`size-4` inline, `size-12` full-page ([motion](#motion)). @@ -175,7 +175,7 @@ Consistent words are part of a consistent UI. ### Interactive states -[Core Constraints](../design.md#core-constraints-non-negotiable) covers hover/focus (CSS pseudo-classes, never React state). For completeness every interactive control also needs: +[UI guidelines](../develop.md#ui) cover hover/focus (CSS pseudo-classes, never React state). For completeness every interactive control also needs: - **Disabled:** the shadcn primitives already apply `disabled:opacity-50 disabled:pointer-events-none` — reuse them; don't hand-roll a greyed-out look. A disabled control still needs a reason nearby (helper text/tooltip) if it's non-obvious. - **Active / pressed:** rely on the primitive's built-in `active:`; add `active:` utilities only for custom controls. diff --git a/docs/references/design-tokens.md b/docs/references/design-tokens.md index 5b9025099..c8c16679e 100644 --- a/docs/references/design-tokens.md +++ b/docs/references/design-tokens.md @@ -7,7 +7,8 @@ **Usage:** - Background `bg-`, text `text-`, border `border-`, focus ring `ring-ring`. - Opacity modifiers compose directly: `bg-primary-background/90` (solid primary hover), `ring-destructive/20`, `bg-input/30`. -- **Never hard-code a color value** — see Constraint 1 and [`develop.md` § UI](../develop.md#ui). For dark-only tweaks use the `dark:` variant. +- **Never hard-code a color value** — see [`develop.md` § UI](../develop.md#ui). For dark-only tweaks use the `dark:` variant. +- Use one token per semantic color concept. Reuse an existing token; add one only for a new concept with light and dark values, and document its role here. ### Base surfaces & text diff --git a/docs/references/develop-testing.md b/docs/references/develop-testing.md index fab471b13..322a17f39 100644 --- a/docs/references/develop-testing.md +++ b/docs/references/develop-testing.md @@ -334,16 +334,7 @@ before/after in one environment with the JSON-report method below. pay for a full accessibility-tree `*ByRole` scan when the role itself is not the behavior under test. - Accessibility coverage must not be weakened for speed. When role/ARIA derivation is the contract, assert the resulting `role` / `aria-*` attribute directly (or use the semantic query in a small, focused component test). -- Choose the narrowest async primitive that matches the production boundary: - - If an event handler calls the observed mock synchronously, assert immediately; `waitFor` only adds polling. - - For an element that appears after an effect or request, use `findBy*` instead of wrapping `screen.getBy*` in - `waitFor`. - - When a resolved Promise drives React state, locate the control first, trigger it inside one - `await act(async () => ...)`, then assert directly. Do not put a `findBy*` query inside `act`. - - Keep `waitFor` for genuinely open-ended async boundaries (deferred effects, externally controlled Promises, - Portal mounting). Keep its callback cheap and scoped, and combine related assertions into one polling loop. -- Avoid real sleeps in unit tests. Use fake timers for timer behavior; a short real delay is acceptable only when - the delay itself is the regression guard (for example, proving a rejected load does not start a runaway loop). +- Select waits according to the [asynchronous observation rules](#observation-rules-for-asynchronous-tests). - Match test concurrency to the workload: - Use `describe.concurrent()` / `it.concurrent()` only when cases can make useful progress without blocking the same worker. Synchronous CPU-heavy work such as parsing, encoding, compression, and large fixture loops still diff --git a/docs/references/terminology-ko-KR.md b/docs/references/terminology-ko-KR.md index c0b8f3e42..90220dc0b 100644 --- a/docs/references/terminology-ko-KR.md +++ b/docs/references/terminology-ko-KR.md @@ -72,7 +72,7 @@ | 개념 | 사용할 수 있는 표현 | 선택 기준 | 예시 key | | --- | --- | --- | --- | -| source | `출처`, `설치 출처`, `구독 출처`, `소스 코드` | origin/provenance는 `출처`, code는 `소스 코드`를 사용합니다. | `source`, `col_source`, `prompt.source` | +| source | `출처`, `설치 출처`, `구독 출처`, `소스 코드` | origin/provenance는 `출처`, code는 `소스 코드`를 사용합니다. | `source`, `col_source` | | local / cloud | `로컬` / `클라우드` | 데이터 위치, 백업 위치, 동기화 대상을 설명합니다. | `local`, `cloud`, `backup_to` | | storage | `저장소`, `저장 공간` | 기능 이름과 짧은 레이블은 `저장소`, 공간을 설명하는 문장은 `저장 공간`을 사용할 수 있습니다. | `script_storage`, `storage_error` | | panel / console | `패널` / `콘솔` | ScriptCat 조작 UI는 `패널`, 개발자 도구 출력은 `콘솔`을 사용합니다. | `background_script_description`, `build_success_message` | @@ -149,7 +149,7 @@ | `인증` / 권한 허용 | authorization을 authentication으로 오역할 수 있음 | 권한 결정은 `권한 허용`·`권한 요청`, 계정 검증만 `인증` | `auth_duration`, `loading_confirm` | | 일반 스크립트 / 유저스크립트 | 제품 유형과 일반 생태계 용어가 섞일 수 있음 | 제품 유형은 `일반 스크립트`, 일반 개념은 `유저스크립트` | `create_user_script`, `thisIsAUserScript` | | 예약 스크립트 / crontab script | 유형 이름과 문법 이름이 섞일 수 있음 | 유형은 `예약 스크립트`, 문법은 `cron 표현식` | `only_background_scheduled_can_run` | -| `소스` / `출처` | origin과 source code가 혼동될 수 있음 | origin은 `출처`, code는 `소스 코드` | `common:source`, `prompt.source` | +| `소스` / `출처` | origin과 source code가 혼동될 수 있음 | origin은 `출처`, code는 `소스 코드` | `common:source`, `editor:source` | | `Skill` / `스킬` | 동일한 기능 이름이 혼용될 수 있음 | 일반 UI는 `스킬`, 정확한 식별자는 원문 유지 | `import_skill`, `skills_title` | | 브라우저 탭 | bare `전체`가 무엇을 뜻하는지 불명확할 수 있음 | `모든 탭`, `일반 탭`, `시크릿 탭` | `script_run_env.*` | | clear / reset | 데이터 비우기와 기본값 초기화가 혼동될 수 있음 | `비우기`·`지우기`와 `초기화`를 구분 | `clear_success`, `reset` | diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md index a3991b3b4..abeb8accc 100644 --- a/example/tests/lib/README.md +++ b/example/tests/lib/README.md @@ -6,7 +6,7 @@ ## 引入 ```js -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js ``` E2E 运行时会把该框架 URL 重写到本地 mock server(见 `e2e/gm-api.spec.ts` 的 diff --git a/packages/chrome-extension-mock/README.md b/packages/chrome-extension-mock/README.md index 8e691efdc..691f78bb8 100644 --- a/packages/chrome-extension-mock/README.md +++ b/packages/chrome-extension-mock/README.md @@ -1,3 +1,5 @@ # mock一个chrome扩展环境 -> 只针对自己的项目做了一些简单的封装,如果有需要可以自己修改 +`@Packages/chrome-extension-mock` 的默认导出 `chromeMock` 为测试提供 `chrome.*` API mock。仓库测试在 +[`tests/vitest.setup.ts`](../../tests/vitest.setup.ts) 中将其注册为全局 `chrome` 并调用 `init()`,以重置下载、权限和 +WebRequest mock 的状态。 diff --git a/packages/message/README.md b/packages/message/README.md index a4206d28e..44f6fa01e 100644 --- a/packages/message/README.md +++ b/packages/message/README.md @@ -1,12 +1,13 @@ # 消息 -跨 context(service_worker / content / inject / offscreen / sandbox)消息交互的抽象层。按调用形态选择传输方式: +跨 context(service_worker / content / inject / offscreen / sandbox)消息交互的抽象层,也包含与 +`scripting` 页面桥接辅助脚本的消息。按调用形态选择传输方式: - **单次 request/reply**(调用一次拿一次结果,例如大多数 GM API、扩展页面对 service_worker 的一次性调用)—— 使用 `sendMessage`(`Server`/`Group`/`Client` 的 RPC 封装)。 - **流式/进度/长响应,或需要持续双向交换**(例如需要分块返回大响应的 GM API、需要多次调用/多次结果的场景)—— 使用 `connect()`(`MessageConnect`)建立持久连接。 -- **广播**(service_worker/offscreen 触发的状态变化需要通知所有页面)——使用 `MessageQueue` 的 +- **广播**(service_worker/offscreen 的状态变化需要通知已实例化 `MessageQueue` 并订阅对应 topic 的上下文)——使用 `MessageQueue` 的 `publish`/`subscribe`,而不是上面两种点对点方式。 Service Worker → Offscreen 在 Chrome 与 Firefox 上走不同路径(Chrome 使用 From 1937c3187178688321d3764a38cd73c35178e68f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:12:19 +0900 Subject: [PATCH 105/106] =?UTF-8?q?=F0=9F=A7=AA=20=E4=BF=AE=E5=A4=8D=20Vit?= =?UTF-8?q?est=20=E6=B5=8B=E8=AF=95=E6=A1=86=E6=9E=B6=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/early_inject_content_test.js | 2 +- example/tests/early_inject_page_test.js | 2 +- example/tests/gm_api_async_test.js | 2 +- example/tests/gm_api_sync_test.js | 2 +- example/tests/gm_download_test.js | 2 +- example/tests/gm_menu_test.js | 2 +- example/tests/gm_value_test.js | 2 +- example/tests/gm_xhr_cookie_test.js | 2 +- example/tests/gm_xhr_redirect_test.js | 2 +- example/tests/gm_xhr_test.js | 2 +- example/tests/inject_content_test.js | 2 +- example/tests/lib/sctest.test.js | 2 +- example/tests/sandbox_compatibility_test.js | 2 +- example/tests/sandbox_function_test.js | 2 +- example/tests/unwrap_e2e_test.js | 2 +- example/tests/unwrap_test.js | 2 +- example/tests/window_message_test.js | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index 08f8f23f5..5674ac347 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -15,7 +15,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index 7752b86b1..59719c021 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -14,7 +14,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index 221fccba9..712d5394e 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -23,7 +23,7 @@ // @grant GM.cookie // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index f57875162..cd63c5d02 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -27,7 +27,7 @@ // @grant GM.setValue // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 32d1666b4..cc93a66e1 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -11,7 +11,7 @@ // @grant GM_setValue // @grant GM_getValue // @grant GM_info -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect httpbingo.org // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index 842e850de..35fa6ebdd 100644 --- a/example/tests/gm_menu_test.js +++ b/example/tests/gm_menu_test.js @@ -6,7 +6,7 @@ // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // ==/UserScript== (async function () { diff --git a/example/tests/gm_value_test.js b/example/tests/gm_value_test.js index 24076c706..a8e23984c 100644 --- a/example/tests/gm_value_test.js +++ b/example/tests/gm_value_test.js @@ -9,7 +9,7 @@ // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-idle // ==/UserScript== diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index cfab9e8de..d3e3dc990 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -5,7 +5,7 @@ // @description 验证 GM_xmlhttpRequest 的 cookie 参数语义:脚本指定的名称完全覆盖,未指定的名称原样保留(含同名多值场景) // @match https://mockhttp.org/*?GM_XHR_COOKIE_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect mockhttp.org // @noframes // ==/UserScript== diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index 7f1814f48..1076d038b 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_REDIRECT_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect httpbingo.org // @noframes // ==/UserScript== diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 5acfbe5ad..2a5c10075 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect httpbingo.org // @connect nonexistent-domain-abcxyz.test // @connect raw.githubusercontent.com diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index 1c67c9432..02e43e753 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -14,7 +14,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 4cc6f0e67..0478c5951 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { beforeEach, describe as vdescribe, expect as vexpect, it as vit, vi } from "vitest"; const SCTEST_REQUIRE_URL = - "https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js"; + "https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js"; async function loadSCTest() { delete globalThis.SCTest; diff --git a/example/tests/sandbox_compatibility_test.js b/example/tests/sandbox_compatibility_test.js index 4d07999ca..7ff5d7e81 100644 --- a/example/tests/sandbox_compatibility_test.js +++ b/example/tests/sandbox_compatibility_test.js @@ -15,7 +15,7 @@ // @grant GM.setValue // @grant GM.deleteValue // @grant window.onurlchange -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @inject-into content // ==/UserScript== diff --git a/example/tests/sandbox_function_test.js b/example/tests/sandbox_function_test.js index 81ada25fc..54e067bc6 100644 --- a/example/tests/sandbox_function_test.js +++ b/example/tests/sandbox_function_test.js @@ -19,7 +19,7 @@ // @grant window.close // @grant window.focus // @grant unsafeWindow -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @run-at document-end // ==/UserScript== diff --git a/example/tests/unwrap_e2e_test.js b/example/tests/unwrap_e2e_test.js index 9afa5fcc9..1f7ec1620 100644 --- a/example/tests/unwrap_e2e_test.js +++ b/example/tests/unwrap_e2e_test.js @@ -6,7 +6,7 @@ // @author ScriptCat // @match https://content-security-policy.com/?unwrap_e2e_test // @grant GM_setValue -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @unwrap // ==/UserScript== diff --git a/example/tests/unwrap_test.js b/example/tests/unwrap_test.js index fc2ca51d1..66f791c6a 100644 --- a/example/tests/unwrap_test.js +++ b/example/tests/unwrap_test.js @@ -8,7 +8,7 @@ // @exclude /test_\w+_excluded/ // @grant GM_setValue // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @unwrap // ==/UserScript== diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js index 08a5eb23b..7a0893f88 100644 --- a/example/tests/window_message_test.js +++ b/example/tests/window_message_test.js @@ -9,7 +9,7 @@ // @grant GM_xmlhttpRequest // @grant GM.setClipboard // @grant unsafeWindow -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js // @connect httpbingo.org // @run-at document-end // @noframes From 6a471dfe424e32cf93ff9d7495c3601e97b906c8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:20:33 +0900 Subject: [PATCH 106/106] revert sctest version from 8d6f7eb7319601d0c76a58595803dcbf37b24c12 to b8c6d0839c75ee5e4e4276dd10e201011c445df8 --- example/tests/early_inject_content_test.js | 2 +- example/tests/early_inject_page_test.js | 2 +- example/tests/gm_api_async_test.js | 2 +- example/tests/gm_api_sync_test.js | 2 +- example/tests/gm_download_test.js | 2 +- example/tests/gm_menu_test.js | 2 +- example/tests/gm_value_test.js | 2 +- example/tests/gm_xhr_cookie_test.js | 2 +- example/tests/gm_xhr_redirect_test.js | 2 +- example/tests/gm_xhr_test.js | 2 +- example/tests/inject_content_test.js | 2 +- example/tests/lib/README.md | 2 +- example/tests/lib/sctest.test.js | 2 +- example/tests/sandbox_compatibility_test.js | 2 +- example/tests/sandbox_function_test.js | 2 +- example/tests/unwrap_e2e_test.js | 2 +- example/tests/unwrap_test.js | 2 +- example/tests/window_message_test.js | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index 5674ac347..08f8f23f5 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -15,7 +15,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index 59719c021..7752b86b1 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -14,7 +14,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index 712d5394e..221fccba9 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -23,7 +23,7 @@ // @grant GM.cookie // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index cd63c5d02..f57875162 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -27,7 +27,7 @@ // @grant GM.setValue // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index cc93a66e1..32d1666b4 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -11,7 +11,7 @@ // @grant GM_setValue // @grant GM_getValue // @grant GM_info -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @connect httpbingo.org // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index 35fa6ebdd..842e850de 100644 --- a/example/tests/gm_menu_test.js +++ b/example/tests/gm_menu_test.js @@ -6,7 +6,7 @@ // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // ==/UserScript== (async function () { diff --git a/example/tests/gm_value_test.js b/example/tests/gm_value_test.js index a8e23984c..24076c706 100644 --- a/example/tests/gm_value_test.js +++ b/example/tests/gm_value_test.js @@ -9,7 +9,7 @@ // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @run-at document-idle // ==/UserScript== diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index d3e3dc990..cfab9e8de 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -5,7 +5,7 @@ // @description 验证 GM_xmlhttpRequest 的 cookie 参数语义:脚本指定的名称完全覆盖,未指定的名称原样保留(含同名多值场景) // @match https://mockhttp.org/*?GM_XHR_COOKIE_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @connect mockhttp.org // @noframes // ==/UserScript== diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index 1076d038b..7f1814f48 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_REDIRECT_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @connect httpbingo.org // @noframes // ==/UserScript== diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 2a5c10075..5acfbe5ad 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @connect httpbingo.org // @connect nonexistent-domain-abcxyz.test // @connect raw.githubusercontent.com diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index 02e43e753..1c67c9432 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -14,7 +14,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md index abeb8accc..a3991b3b4 100644 --- a/example/tests/lib/README.md +++ b/example/tests/lib/README.md @@ -6,7 +6,7 @@ ## 引入 ```js -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js ``` E2E 运行时会把该框架 URL 重写到本地 mock server(见 `e2e/gm-api.spec.ts` 的 diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 0478c5951..4cc6f0e67 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { beforeEach, describe as vdescribe, expect as vexpect, it as vit, vi } from "vitest"; const SCTEST_REQUIRE_URL = - "https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js"; + "https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js"; async function loadSCTest() { delete globalThis.SCTest; diff --git a/example/tests/sandbox_compatibility_test.js b/example/tests/sandbox_compatibility_test.js index 7ff5d7e81..4d07999ca 100644 --- a/example/tests/sandbox_compatibility_test.js +++ b/example/tests/sandbox_compatibility_test.js @@ -15,7 +15,7 @@ // @grant GM.setValue // @grant GM.deleteValue // @grant window.onurlchange -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @inject-into content // ==/UserScript== diff --git a/example/tests/sandbox_function_test.js b/example/tests/sandbox_function_test.js index 54e067bc6..81ada25fc 100644 --- a/example/tests/sandbox_function_test.js +++ b/example/tests/sandbox_function_test.js @@ -19,7 +19,7 @@ // @grant window.close // @grant window.focus // @grant unsafeWindow -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @run-at document-end // ==/UserScript== diff --git a/example/tests/unwrap_e2e_test.js b/example/tests/unwrap_e2e_test.js index 1f7ec1620..9afa5fcc9 100644 --- a/example/tests/unwrap_e2e_test.js +++ b/example/tests/unwrap_e2e_test.js @@ -6,7 +6,7 @@ // @author ScriptCat // @match https://content-security-policy.com/?unwrap_e2e_test // @grant GM_setValue -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @unwrap // ==/UserScript== diff --git a/example/tests/unwrap_test.js b/example/tests/unwrap_test.js index 66f791c6a..fc2ca51d1 100644 --- a/example/tests/unwrap_test.js +++ b/example/tests/unwrap_test.js @@ -8,7 +8,7 @@ // @exclude /test_\w+_excluded/ // @grant GM_setValue // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @unwrap // ==/UserScript== diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js index 7a0893f88..08a5eb23b 100644 --- a/example/tests/window_message_test.js +++ b/example/tests/window_message_test.js @@ -9,7 +9,7 @@ // @grant GM_xmlhttpRequest // @grant GM.setClipboard // @grant unsafeWindow -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@8d6f7eb7319601d0c76a58595803dcbf37b24c12/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@b8c6d0839c75ee5e4e4276dd10e201011c445df8/example/tests/lib/sctest.js // @connect httpbingo.org // @run-at document-end // @noframes