diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index e4bf62ed9..468ef94fe 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -214,9 +214,27 @@ describe("utils", () => { expect(result).toBeDefined(); expect(result).toContain( - `GM_registerMenuCommand(("ScriptCat's demo for \\"context-menu\\""), ()=>{let GM_registerMenuCommand=window.GM_registerMenuCommand=GM.registerMenuCommand=undefined;\nconsole.log(567); // testing\n}, {nested:false});\n` + `GM_registerMenuCommand(("ScriptCat's demo for \\"context-menu\\""), ()=>{\nconsole.log(567); // testing\n}, {nested:false});\n` ); }); + + it.concurrent("@run-at context-menu 的包装不得屏蔽脚本体自己的 GM_registerMenuCommand", () => { + const scriptRes = createMockScriptRes({ + name: "menu registering script", + code: 'GM_registerMenuCommand("Own Item", () => {});', + metadata: { + "run-at": ["context-menu"], + }, + }); + + const result = compileScriptCode(scriptRes); + + // 曾经在回调开头把 GM_registerMenuCommand 连同 window./GM. 上的引用一起置为 undefined, + // 于是任何在脚本体里注册菜单的脚本一点菜单就 TypeError 中断,自己的菜单项也永远注册不上 + expect(result).not.toContain("GM_registerMenuCommand=undefined"); + expect(result).not.toContain("window.GM_registerMenuCommand"); + expect(result).not.toContain("GM.registerMenuCommand"); + }); }); describe("compileScript", () => { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index bc7905a0b..8f464773d 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -141,7 +141,8 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) // 在UserScripts API中,由于执行不是在物件导向里呼叫,使用arrow function的话会把this改变。须使用 .call(this) [ 或 .bind(this)() ] if (resource.isContextMenu) { - code = `GM_registerMenuCommand((${JSON.stringify(resource.name)}), ()=>{let GM_registerMenuCommand=window.GM_registerMenuCommand=GM.registerMenuCommand=undefined;\n${code}\n}, {nested:false});\n`; + // 脚本体整体延后到菜单回调里执行,它自己的 GM_registerMenuCommand 也随之推迟到点击后才注册 + code = `GM_registerMenuCommand((${JSON.stringify(resource.name)}), ()=>{\n${code}\n}, {nested:false});\n`; } const joinedCode = [ 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 36d0c6eed..853ebc3aa 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -36,7 +36,7 @@ import type { } from "../types"; import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; import type { NotificationOptionCache } from "../utils"; -import { BrowserNoSupport, notificationsUpdate } from "../utils"; +import { BrowserNoSupport, getCombinedMeta, notificationsUpdate } from "../utils"; import { getSkillScriptGrantsByUuid, getSkillScriptNameByUuid, @@ -416,6 +416,10 @@ export default class GMApi { if (!script) { throw new Error("script is not found"); } + // 设置面板改的运行时机等只写 selfMetadata,GM API 校验要看合并后的生效值(#1649) + if (script.selfMetadata) { + script = { ...script, metadata: getCombinedMeta(script.metadata, script.selfMetadata) }; + } } // 订阅脚本的 connect 使用订阅声明的 connect 覆盖脚本自身的 if (script.subscribeUrl) { diff --git a/src/app/service/service_worker/gm_api/gm_api_self_metadata.test.ts b/src/app/service/service_worker/gm_api/gm_api_self_metadata.test.ts new file mode 100644 index 000000000..d8b3f9ab4 --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_api_self_metadata.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ScriptDAO, SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts"; +import type { Script } from "@App/app/repo/scripts"; +import GMApi, { MockGMExternalDependencies } from "./gm_api"; +import { initTestEnv } from "@Tests/utils"; +import { MockMessage } from "@Packages/message/mock_message"; +import { Server, type IGetSender } from "@Packages/message/server"; +import type { ExtMessageSender } from "@Packages/message/types"; +import EventEmitter from "eventemitter3"; +import { MessageQueue } from "@Packages/message/message_queue"; +import { SystemConfig } from "@App/pkg/config/config"; +import PermissionVerify, { PermissionVerifyApiGet } from "../permission_verify"; +import type { ValueService } from "../value"; + +initTestEnv(); + +const makeSender = (): IGetSender => ({ + getSender: () => ({}) as chrome.runtime.MessageSender, + getType: () => 0, + isType: (_type: any) => false, + getExtMessageSender: () => null as unknown as ExtMessageSender, + getConnect: () => undefined, +}); + +const createGMApi = () => { + const ee = new EventEmitter(); + const message = new MockMessage(ee); + const messageQueue = new MessageQueue(); + const systemConfig = new SystemConfig(messageQueue); + const server = new Server("serviceWorker", message); + const permissionVerify = new PermissionVerify(server.group("permissionVerify"), messageQueue); + const gmApi = new GMApi( + systemConfig, + permissionVerify, + server.group("runtime"), + message, + messageQueue, + {} as ValueService, + new MockGMExternalDependencies() + ); + return { gmApi, permissionVerify }; +}; + +// 设置面板把运行时机改成 context-menu 只写 selfMetadata,脚本自带 metadata 不变; +// GM API 请求若只带自带 metadata,免 @grant 的菜单豁免就判不出来,菜单项注册不上(#1649)。 +describe("parseRequest 用户覆写的 metadata", () => { + let scriptDAO: ScriptDAO; + + beforeEach(() => { + scriptDAO = new ScriptDAO(); + }); + + it("selfMetadata 覆写 run-at=context-menu 的脚本,GM_registerMenuCommand 无需 @grant 即可通过校验", async () => { + const script: Script = { + uuid: "uuid-context-menu-override", + name: "test-script", + namespace: "test", + metadata: { grant: ["none"], "run-at": ["document-idle"] }, + selfMetadata: { "run-at": ["context-menu"] }, + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_ENABLE, + sort: 0, + runStatus: SCRIPT_RUN_STATUS_COMPLETE, + createtime: Date.now(), + checktime: Date.now(), + }; + await scriptDAO.save(script); + const { gmApi, permissionVerify } = createGMApi(); + + const req = await gmApi.parseRequest({ + uuid: script.uuid, + api: "GM_registerMenuCommand", + runFlag: "", + params: [], + }); + + await expect( + permissionVerify.verify(req, PermissionVerifyApiGet("GM_registerMenuCommand")!, makeSender(), gmApi) + ).resolves.toBe(true); + }); +}); diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 27103b47c..cf4cc9c42 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1192,3 +1192,83 @@ describe("MQ 事件处理效果(enableScripts / deleteScripts / sortedScripts expect((runtime as any).cachedPatterns.has(uuid)).toBe(false); }); }); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("restoreJSCodeFromCompiledResource 还原代码时的生效 metadata", () => { + // 设置面板改运行时机只写 selfMetadata,脚本自带 metadata 原封不动; + // 还原路径若只看自带 metadata,重新注册后用户覆写就会静默失效(#1649)。 + const createContext = (script: Script) => { + const { runtime, mockScriptService } = _createRuntimeContext(); + const scriptRes = _createScriptRunResource(script); + const compiledResource: CompiledResource = { + name: script.name, + flag: `#-${script.uuid}`, + uuid: script.uuid, + require: [], + matches: ["https://www.example.com/*"], + includeGlobs: [], + excludeMatches: [], + excludeGlobs: [], + allFrames: false, + world: "MAIN", + runAt: "document_idle", + scriptUrlPatterns: scriptURLPatternResults(scriptRes)!.scriptUrlPatterns, + originalUrlPatterns: null, + }; + mockScriptService.buildScriptRunResource.mockResolvedValue(scriptRes); + (runtime as any).script = { + ...mockScriptService, + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "console.log(1);" }) }, + }; + (runtime as any).resource = { resourceDAO: { get: vi.fn().mockResolvedValue(undefined) } }; + return { runtime, compiledResource }; + }; + + it("selfMetadata 覆写 run-at=context-menu 时,还原的代码应包裹 GM_registerMenuCommand", async () => { + const script = _createMockScript({ + metadata: { match: ["https://www.example.com/*"], "run-at": ["document-idle"] }, + selfMetadata: { "run-at": ["context-menu"] }, + }); + const { runtime, compiledResource } = createContext(script); + + const code = await runtime.restoreJSCodeFromCompiledResource(script, compiledResource); + + expect(code).toContain("GM_registerMenuCommand"); + }); + + it("selfMetadata 覆写为 early-start 时,还原的代码应走预注入编译", async () => { + const script = _createMockScript({ + metadata: { match: ["https://www.example.com/*"], "run-at": ["document-idle"] }, + selfMetadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const { runtime, compiledResource } = createContext(script); + + const code = await runtime.restoreJSCodeFromCompiledResource(script, compiledResource); + + expect(code).toContain("performance.dispatchEvent"); + }); +}); + +describe("pushValueUpdate 判断是否需要为 early-start 脚本重新编译", () => { + // early-start 会把 GM 值编进预注入代码,值变了必须重编; + // 该脚本的 early-start 可能来自用户覆写,不能只看脚本自带 metadata。 + it("selfMetadata 覆写为 early-start 的脚本,值更新后应重新编译注册", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createMockScript({ + metadata: { match: ["https://www.example.com/*"], "run-at": ["document-idle"] }, + selfMetadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const updateSpy = vi.spyOn(runtime, "updateResourceOnScriptChange").mockResolvedValue(undefined); + + await runtime.pushValueUpdate(script, { + entries: [], + uuid: script.uuid, + storageName: "test-storage", + sender: { runFlag: "", tabId: -1 }, + valueUpdated: true, + }); + + expect(updateSpy).toHaveBeenCalledWith(script); + }); +}); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index ca06143f1..4a33c67b8 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -13,6 +13,7 @@ import { runScript, stopScript } from "../offscreen/client"; import { buildScriptRunResourceBasic, compileInjectionCode, + getCombinedMeta, getUserScriptRegister, parseUrlSRI, scriptURLPatternResults, @@ -485,7 +486,10 @@ export class RuntimeService { // valueUpdate 消息用于 early script 的处理 if (sendData.valueUpdated) { - if (script.status === SCRIPT_STATUS_ENABLE && isEarlyStartScript(script.metadata)) { + if ( + script.status === SCRIPT_STATUS_ENABLE && + isEarlyStartScript(getCombinedMeta(script.metadata, script.selfMetadata)) + ) { // 如果是预加载脚本,需要更新脚本代码重新注册 // scriptMatchInfo 里的 value 改变 => compileInjectionCode -> injectionCode 改变 await this.updateResourceOnScriptChange(script); @@ -902,15 +906,19 @@ export class RuntimeService { // 从CompiledResource中还原脚本代码 async restoreJSCodeFromCompiledResource(script: Script, result: CompiledResource) { + // 用户在设置面板改运行时机只写 selfMetadata,脚本自带 metadata 不变, + // 所以编译分支必须按合并后的生效 metadata 选,否则重新注册会丢掉覆写(#1649) + const metadata = getCombinedMeta(script.metadata, script.selfMetadata); + // 如果是 Scriptlet (unwrap) 脚本,需要另外的处理方式 - if (isScriptletUnwrap(script.metadata)) { + if (isScriptletUnwrap(metadata)) { const scriptRes = await this.script.buildScriptRunResource(script); if (!scriptRes) return ""; return compileScriptletCode(scriptRes, scriptRes.code, result.scriptUrlPatterns); } // 如果是预加载脚本,需要另外的处理方式 - if (isEarlyStartScript(script.metadata)) { + if (isEarlyStartScript(metadata)) { const scriptRes = await this.script.buildScriptRunResource(script); if (!scriptRes) return ""; return compileInjectionCode(scriptRes, scriptRes.code, result.scriptUrlPatterns); @@ -931,7 +939,7 @@ export class RuntimeService { name: result.name, code: originalCode?.code || "", require, - isContextMenu: isContextMenuScript(script.metadata), + isContextMenu: isContextMenuScript(metadata), }) ); } diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index 753befa28..456013438 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -137,7 +137,7 @@ export async function notificationsUpdate( } } -export function getCombinedMeta(metaBase: SCMetadata, metaCustom: SCMetadata): SCMetadata { +export function getCombinedMeta(metaBase: SCMetadata, metaCustom: SCMetadata | undefined): SCMetadata { const metaRet = { ...metaBase }; if (!metaCustom) { return metaRet;