From 5340035fcbd9596656c530ecfdb1a552003c225b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 3 Sep 2026 09:46:04 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=9D=A2=E6=9D=BF=E8=A6=86=E5=86=99=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E6=9C=BA=E5=9C=A8=E9=87=8D=E6=96=B0=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=90=8E=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restoreJSCodeFromCompiledResource 用脚本自带 metadata 选择编译分支, 而设置面板改运行时机/early-start 只写 selfMetadata,导致全量重新注册 (扩展更新、切换启用脚本、改黑名单等)后覆写被丢弃:context-menu 脚本 恢复自动执行且不注册菜单项,early-start 退化为普通注入。 pushValueUpdate 判断 early-start 时同样只看自带 metadata,覆写而来的 early-start 脚本在 GM 值变更后不会重新编译,预注入代码里的值会过期。 close #1649 --- .../service/service_worker/runtime.test.ts | 80 +++++++++++++++++++ src/app/service/service_worker/runtime.ts | 16 +++- src/app/service/service_worker/utils.ts | 2 +- 3 files changed, 93 insertions(+), 5 deletions(-) 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; From 8422c930d2042226025b1a4021d7b685e60fa30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 3 Sep 2026 10:25:18 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=20AP?= =?UTF-8?q?I=20=E6=9D=83=E9=99=90=E6=A0=A1=E9=AA=8C=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=A6=86=E5=86=99=E7=9A=84=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GMApi.parseRequest 直接把 scriptDAO 里的原始 Script 放进 GMApiRequest, metadata 没有合并 selfMetadata。PermissionVerify 对 context-menu 脚本的 GM_registerMenuCommand 免 @grant 豁免因此判不出来,浏览器里表现为 verify error {"api":"GM_registerMenuCommand","error":"permission not requested"}, 菜单项注册不上 —— 即 #1649 里「上下文菜单中没有出现执行选项」。 真实浏览器验证记录见 e2e/scratch/run-at-override/report.md(未入库)。 --- .../service/service_worker/gm_api/gm_api.ts | 6 +- .../gm_api/gm_api_self_metadata.test.ts | 81 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/app/service/service_worker/gm_api/gm_api_self_metadata.test.ts 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); + }); +}); From 1e811242c6ecb67225a008ea43fb5b2921856203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 3 Sep 2026 11:22:38 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20context-menu=20=E5=8C=85?= =?UTF-8?q?=E8=A3=85=E4=B8=8D=E5=86=8D=E5=B1=8F=E8=94=BD=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E4=BD=93=E8=87=AA=E5=B7=B1=E7=9A=84=20GM=5FregisterMenuCommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @run-at context-menu 的包装把脚本体塞进菜单回调时,回调开头把 GM_registerMenuCommand 连同 window./GM. 上的引用一起置为 undefined。 于是任何在脚本体里注册菜单的脚本,点菜单执行就会 TypeError: GM_registerMenuCommand is not a function 当场中断, 它自己的菜单项也永远注册不上——用户看到的是「GM_registerMenu 的菜单显示不出来」。 该置空还会污染页面 window 与该脚本的 GM 物件,且是持久的。 去掉这行,脚本体里的菜单注册照常工作。代价是脚本体每次被点执行都会重新注册 一次,内部条目累积(显示层按 groupKey 去重,不会出现重复菜单项,但同名项的 回调会被触发多次)。 真实浏览器验证记录见 e2e/scratch/ctx-menu-{display,fix}/(未入库)。 --- src/app/service/content/utils.test.ts | 20 +++++++++++++++++++- src/app/service/content/utils.ts | 3 ++- 2 files changed, 21 insertions(+), 2 deletions(-) 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 = [