Skip to content

Commit afe97d8

Browse files
authored
🐛 修复 @run-at context-menu:设置覆写不生效、菜单注册不上、脚本体自己的菜单被屏蔽 (#1718)
* 🐛 修复设置面板覆写运行时机在重新注册后失效 restoreJSCodeFromCompiledResource 用脚本自带 metadata 选择编译分支, 而设置面板改运行时机/early-start 只写 selfMetadata,导致全量重新注册 (扩展更新、切换启用脚本、改黑名单等)后覆写被丢弃:context-menu 脚本 恢复自动执行且不注册菜单项,early-start 退化为普通注入。 pushValueUpdate 判断 early-start 时同样只看自带 metadata,覆写而来的 early-start 脚本在 GM 值变更后不会重新编译,预注入代码里的值会过期。 close #1649 * 🐛 修复 GM API 权限校验忽略用户覆写的运行时机 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(未入库)。 * 🐛 context-menu 包装不再屏蔽脚本体自己的 GM_registerMenuCommand @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}/(未入库)。
1 parent 61164f6 commit afe97d8

7 files changed

Lines changed: 200 additions & 8 deletions

File tree

‎src/app/service/content/utils.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,27 @@ describe("utils", () => {
214214

215215
expect(result).toBeDefined();
216216
expect(result).toContain(
217-
`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`
217+
`GM_registerMenuCommand(("ScriptCat's demo for \\"context-menu\\""), ()=>{\nconsole.log(567); // testing\n}, {nested:false});\n`
218218
);
219219
});
220+
221+
it.concurrent("@run-at context-menu 的包装不得屏蔽脚本体自己的 GM_registerMenuCommand", () => {
222+
const scriptRes = createMockScriptRes({
223+
name: "menu registering script",
224+
code: 'GM_registerMenuCommand("Own Item", () => {});',
225+
metadata: {
226+
"run-at": ["context-menu"],
227+
},
228+
});
229+
230+
const result = compileScriptCode(scriptRes);
231+
232+
// 曾经在回调开头把 GM_registerMenuCommand 连同 window./GM. 上的引用一起置为 undefined,
233+
// 于是任何在脚本体里注册菜单的脚本一点菜单就 TypeError 中断,自己的菜单项也永远注册不上
234+
expect(result).not.toContain("GM_registerMenuCommand=undefined");
235+
expect(result).not.toContain("window.GM_registerMenuCommand");
236+
expect(result).not.toContain("GM.registerMenuCommand");
237+
});
220238
});
221239

222240
describe("compileScript", () => {

‎src/app/service/content/utils.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource)
141141
// 在UserScripts API中,由于执行不是在物件导向里呼叫,使用arrow function的话会把this改变。须使用 .call(this) [ 或 .bind(this)() ]
142142

143143
if (resource.isContextMenu) {
144-
code = `GM_registerMenuCommand((${JSON.stringify(resource.name)}), ()=>{let GM_registerMenuCommand=window.GM_registerMenuCommand=GM.registerMenuCommand=undefined;\n${code}\n}, {nested:false});\n`;
144+
// 脚本体整体延后到菜单回调里执行,它自己的 GM_registerMenuCommand 也随之推迟到点击后才注册
145+
code = `GM_registerMenuCommand((${JSON.stringify(resource.name)}), ()=>{\n${code}\n}, {nested:false});\n`;
145146
}
146147

147148
const joinedCode = [

‎src/app/service/service_worker/gm_api/gm_api.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import type {
3636
} from "../types";
3737
import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue";
3838
import type { NotificationOptionCache } from "../utils";
39-
import { BrowserNoSupport, notificationsUpdate } from "../utils";
39+
import { BrowserNoSupport, getCombinedMeta, notificationsUpdate } from "../utils";
4040
import {
4141
getSkillScriptGrantsByUuid,
4242
getSkillScriptNameByUuid,
@@ -416,6 +416,10 @@ export default class GMApi {
416416
if (!script) {
417417
throw new Error("script is not found");
418418
}
419+
// 设置面板改的运行时机等只写 selfMetadata,GM API 校验要看合并后的生效值(#1649)
420+
if (script.selfMetadata) {
421+
script = { ...script, metadata: getCombinedMeta(script.metadata, script.selfMetadata) };
422+
}
419423
}
420424
// 订阅脚本的 connect 使用订阅声明的 connect 覆盖脚本自身的
421425
if (script.subscribeUrl) {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import { ScriptDAO, SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts";
3+
import type { Script } from "@App/app/repo/scripts";
4+
import GMApi, { MockGMExternalDependencies } from "./gm_api";
5+
import { initTestEnv } from "@Tests/utils";
6+
import { MockMessage } from "@Packages/message/mock_message";
7+
import { Server, type IGetSender } from "@Packages/message/server";
8+
import type { ExtMessageSender } from "@Packages/message/types";
9+
import EventEmitter from "eventemitter3";
10+
import { MessageQueue } from "@Packages/message/message_queue";
11+
import { SystemConfig } from "@App/pkg/config/config";
12+
import PermissionVerify, { PermissionVerifyApiGet } from "../permission_verify";
13+
import type { ValueService } from "../value";
14+
15+
initTestEnv();
16+
17+
const makeSender = (): IGetSender => ({
18+
getSender: () => ({}) as chrome.runtime.MessageSender,
19+
getType: () => 0,
20+
isType: (_type: any) => false,
21+
getExtMessageSender: () => null as unknown as ExtMessageSender,
22+
getConnect: () => undefined,
23+
});
24+
25+
const createGMApi = () => {
26+
const ee = new EventEmitter<string, any>();
27+
const message = new MockMessage(ee);
28+
const messageQueue = new MessageQueue();
29+
const systemConfig = new SystemConfig(messageQueue);
30+
const server = new Server("serviceWorker", message);
31+
const permissionVerify = new PermissionVerify(server.group("permissionVerify"), messageQueue);
32+
const gmApi = new GMApi(
33+
systemConfig,
34+
permissionVerify,
35+
server.group("runtime"),
36+
message,
37+
messageQueue,
38+
{} as ValueService,
39+
new MockGMExternalDependencies()
40+
);
41+
return { gmApi, permissionVerify };
42+
};
43+
44+
// 设置面板把运行时机改成 context-menu 只写 selfMetadata,脚本自带 metadata 不变;
45+
// GM API 请求若只带自带 metadata,免 @grant 的菜单豁免就判不出来,菜单项注册不上(#1649)。
46+
describe("parseRequest 用户覆写的 metadata", () => {
47+
let scriptDAO: ScriptDAO;
48+
49+
beforeEach(() => {
50+
scriptDAO = new ScriptDAO();
51+
});
52+
53+
it("selfMetadata 覆写 run-at=context-menu 的脚本,GM_registerMenuCommand 无需 @grant 即可通过校验", async () => {
54+
const script: Script = {
55+
uuid: "uuid-context-menu-override",
56+
name: "test-script",
57+
namespace: "test",
58+
metadata: { grant: ["none"], "run-at": ["document-idle"] },
59+
selfMetadata: { "run-at": ["context-menu"] },
60+
type: SCRIPT_TYPE_NORMAL,
61+
status: SCRIPT_STATUS_ENABLE,
62+
sort: 0,
63+
runStatus: SCRIPT_RUN_STATUS_COMPLETE,
64+
createtime: Date.now(),
65+
checktime: Date.now(),
66+
};
67+
await scriptDAO.save(script);
68+
const { gmApi, permissionVerify } = createGMApi();
69+
70+
const req = await gmApi.parseRequest({
71+
uuid: script.uuid,
72+
api: "GM_registerMenuCommand",
73+
runFlag: "",
74+
params: [],
75+
});
76+
77+
await expect(
78+
permissionVerify.verify(req, PermissionVerifyApiGet("GM_registerMenuCommand")!, makeSender(), gmApi)
79+
).resolves.toBe(true);
80+
});
81+
});

‎src/app/service/service_worker/runtime.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,3 +1192,83 @@ describe("MQ 事件处理效果(enableScripts / deleteScripts / sortedScripts
11921192
expect((runtime as any).cachedPatterns.has(uuid)).toBe(false);
11931193
});
11941194
});
1195+
1196+
// ─────────────────────────────────────────────────────────────────────────────
1197+
1198+
describe("restoreJSCodeFromCompiledResource 还原代码时的生效 metadata", () => {
1199+
// 设置面板改运行时机只写 selfMetadata,脚本自带 metadata 原封不动;
1200+
// 还原路径若只看自带 metadata,重新注册后用户覆写就会静默失效(#1649)。
1201+
const createContext = (script: Script) => {
1202+
const { runtime, mockScriptService } = _createRuntimeContext();
1203+
const scriptRes = _createScriptRunResource(script);
1204+
const compiledResource: CompiledResource = {
1205+
name: script.name,
1206+
flag: `#-${script.uuid}`,
1207+
uuid: script.uuid,
1208+
require: [],
1209+
matches: ["https://www.example.com/*"],
1210+
includeGlobs: [],
1211+
excludeMatches: [],
1212+
excludeGlobs: [],
1213+
allFrames: false,
1214+
world: "MAIN",
1215+
runAt: "document_idle",
1216+
scriptUrlPatterns: scriptURLPatternResults(scriptRes)!.scriptUrlPatterns,
1217+
originalUrlPatterns: null,
1218+
};
1219+
mockScriptService.buildScriptRunResource.mockResolvedValue(scriptRes);
1220+
(runtime as any).script = {
1221+
...mockScriptService,
1222+
scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "console.log(1);" }) },
1223+
};
1224+
(runtime as any).resource = { resourceDAO: { get: vi.fn().mockResolvedValue(undefined) } };
1225+
return { runtime, compiledResource };
1226+
};
1227+
1228+
it("selfMetadata 覆写 run-at=context-menu 时,还原的代码应包裹 GM_registerMenuCommand", async () => {
1229+
const script = _createMockScript({
1230+
metadata: { match: ["https://www.example.com/*"], "run-at": ["document-idle"] },
1231+
selfMetadata: { "run-at": ["context-menu"] },
1232+
});
1233+
const { runtime, compiledResource } = createContext(script);
1234+
1235+
const code = await runtime.restoreJSCodeFromCompiledResource(script, compiledResource);
1236+
1237+
expect(code).toContain("GM_registerMenuCommand");
1238+
});
1239+
1240+
it("selfMetadata 覆写为 early-start 时,还原的代码应走预注入编译", async () => {
1241+
const script = _createMockScript({
1242+
metadata: { match: ["https://www.example.com/*"], "run-at": ["document-idle"] },
1243+
selfMetadata: { "early-start": [""], "run-at": ["document-start"] },
1244+
});
1245+
const { runtime, compiledResource } = createContext(script);
1246+
1247+
const code = await runtime.restoreJSCodeFromCompiledResource(script, compiledResource);
1248+
1249+
expect(code).toContain("performance.dispatchEvent");
1250+
});
1251+
});
1252+
1253+
describe("pushValueUpdate 判断是否需要为 early-start 脚本重新编译", () => {
1254+
// early-start 会把 GM 值编进预注入代码,值变了必须重编;
1255+
// 该脚本的 early-start 可能来自用户覆写,不能只看脚本自带 metadata。
1256+
it("selfMetadata 覆写为 early-start 的脚本,值更新后应重新编译注册", async () => {
1257+
const { runtime } = _createRuntimeContext();
1258+
const script = _createMockScript({
1259+
metadata: { match: ["https://www.example.com/*"], "run-at": ["document-idle"] },
1260+
selfMetadata: { "early-start": [""], "run-at": ["document-start"] },
1261+
});
1262+
const updateSpy = vi.spyOn(runtime, "updateResourceOnScriptChange").mockResolvedValue(undefined);
1263+
1264+
await runtime.pushValueUpdate(script, {
1265+
entries: [],
1266+
uuid: script.uuid,
1267+
storageName: "test-storage",
1268+
sender: { runFlag: "", tabId: -1 },
1269+
valueUpdated: true,
1270+
});
1271+
1272+
expect(updateSpy).toHaveBeenCalledWith(script);
1273+
});
1274+
});

‎src/app/service/service_worker/runtime.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { runScript, stopScript } from "../offscreen/client";
1313
import {
1414
buildScriptRunResourceBasic,
1515
compileInjectionCode,
16+
getCombinedMeta,
1617
getUserScriptRegister,
1718
parseUrlSRI,
1819
scriptURLPatternResults,
@@ -485,7 +486,10 @@ export class RuntimeService {
485486

486487
// valueUpdate 消息用于 early script 的处理
487488
if (sendData.valueUpdated) {
488-
if (script.status === SCRIPT_STATUS_ENABLE && isEarlyStartScript(script.metadata)) {
489+
if (
490+
script.status === SCRIPT_STATUS_ENABLE &&
491+
isEarlyStartScript(getCombinedMeta(script.metadata, script.selfMetadata))
492+
) {
489493
// 如果是预加载脚本,需要更新脚本代码重新注册
490494
// scriptMatchInfo 里的 value 改变 => compileInjectionCode -> injectionCode 改变
491495
await this.updateResourceOnScriptChange(script);
@@ -902,15 +906,19 @@ export class RuntimeService {
902906

903907
// 从CompiledResource中还原脚本代码
904908
async restoreJSCodeFromCompiledResource(script: Script, result: CompiledResource) {
909+
// 用户在设置面板改运行时机只写 selfMetadata,脚本自带 metadata 不变,
910+
// 所以编译分支必须按合并后的生效 metadata 选,否则重新注册会丢掉覆写(#1649)
911+
const metadata = getCombinedMeta(script.metadata, script.selfMetadata);
912+
905913
// 如果是 Scriptlet (unwrap) 脚本,需要另外的处理方式
906-
if (isScriptletUnwrap(script.metadata)) {
914+
if (isScriptletUnwrap(metadata)) {
907915
const scriptRes = await this.script.buildScriptRunResource(script);
908916
if (!scriptRes) return "";
909917
return compileScriptletCode(scriptRes, scriptRes.code, result.scriptUrlPatterns);
910918
}
911919

912920
// 如果是预加载脚本,需要另外的处理方式
913-
if (isEarlyStartScript(script.metadata)) {
921+
if (isEarlyStartScript(metadata)) {
914922
const scriptRes = await this.script.buildScriptRunResource(script);
915923
if (!scriptRes) return "";
916924
return compileInjectionCode(scriptRes, scriptRes.code, result.scriptUrlPatterns);
@@ -931,7 +939,7 @@ export class RuntimeService {
931939
name: result.name,
932940
code: originalCode?.code || "",
933941
require,
934-
isContextMenu: isContextMenuScript(script.metadata),
942+
isContextMenu: isContextMenuScript(metadata),
935943
})
936944
);
937945
}

‎src/app/service/service_worker/utils.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export async function notificationsUpdate(
137137
}
138138
}
139139

140-
export function getCombinedMeta(metaBase: SCMetadata, metaCustom: SCMetadata): SCMetadata {
140+
export function getCombinedMeta(metaBase: SCMetadata, metaCustom: SCMetadata | undefined): SCMetadata {
141141
const metaRet = { ...metaBase };
142142
if (!metaCustom) {
143143
return metaRet;

0 commit comments

Comments
 (0)