Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
09e7392
重新修订 `GM_registerMenuCommand` 相关代码设计
cyfung1031 Oct 2, 2025
4c854d0
修正 Popup tsx 状态更新问题
cyfung1031 Oct 2, 2025
ad453bd
简化 grouppedMenus 代码
cyfung1031 Oct 2, 2025
e414b51
小修正
cyfung1031 Oct 2, 2025
3125f3c
Copilot说得对
cyfung1031 Oct 3, 2025
a04c139
Copilot说得对
cyfung1031 Oct 3, 2025
7e8981d
Copilot说得对
cyfung1031 Oct 3, 2025
b779c49
将TODO注释提取为统一的文档或issue。
cyfung1031 Oct 3, 2025
3b6f520
将TODO注释提取为统一的文档或issue。
cyfung1031 Oct 3, 2025
27d828d
lint
cyfung1031 Oct 3, 2025
f7750a1
整理注释成 VSCode IntelliSense 版
cyfung1031 Oct 3, 2025
f099b1e
Merge branch 'main' into GM_registerMenuCommand_codefix
cyfung1031 Oct 5, 2025
cc57167
merge修正
cyfung1031 Oct 5, 2025
0f7280f
[script, grouppedMenus]);
cyfung1031 Oct 5, 2025
8dbc5ec
Merge remote-tracking branch 'upstream/main' into GM_registerMenuComm…
cyfung1031 Oct 7, 2025
2344f7a
Merge remote-tracking branch 'upstream/main' into GM_registerMenuComm…
cyfung1031 Oct 8, 2025
4f676f8
防止热重启打乱 id
cyfung1031 Oct 8, 2025
2d869ba
不需要 contextMenuConvArr
cyfung1031 Oct 8, 2025
05582cd
處理一下 chrome.contextMenus.create 的 Runtime lastError
cyfung1031 Oct 8, 2025
d337b86
添加单元测试
CodFrm Oct 8, 2025
59d74bf
Merge remote-tracking branch 'origin/main' into pr/cyfung1031/790
CodFrm Oct 8, 2025
48824f0
减少 `JSON.stringify` 的呼叫
cyfung1031 Oct 8, 2025
4831c68
中文
cyfung1031 Oct 8, 2025
69c7456
增加单元测试
CodFrm Oct 9, 2025
276e2a6
Update src/app/service/content/gm_api.ts
CodFrm Oct 9, 2025
831503c
Update src/app/service/service_worker/popup.ts
CodFrm Oct 9, 2025
79ac078
删除menuRegisterNeedUpdate 和 menuRegister逻辑
CodFrm Oct 9, 2025
dc14f0f
Merge branch 'GM_registerMenuCommand_codefix' of github.com:cyfung103…
CodFrm Oct 9, 2025
6445c0a
修复并发问题
CodFrm Oct 9, 2025
b8b1e7d
修改为shift
CodFrm Oct 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ class Cache extends ExtCache {
}
});
unlock();
return newValue!;
return newValue!; // 必须注意当 value 为 「undefined, null, "", 0」 时,newValue 是 undefined
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

啥意思?

}

incr(key: string, increase: number): Promise<number> {
Expand Down
137 changes: 136 additions & 1 deletion src/app/service/content/gm_api.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import ExecScript from "./exec_script";
import type { ScriptLoadInfo } from "../service_worker/types";
import type { GMInfoEnv, ScriptFunc } from "./types";
import { compileScript, compileScriptCode } from "./utils";
import type { Message } from "@Packages/message/types";

const nilFn: ScriptFunc = () => {};

Expand Down Expand Up @@ -210,3 +211,137 @@ describe("early-script", () => {
expect(await ret).toEqual(123);
});
});

describe("GM_menu", () => {
it("注册菜单", async () => {
const script = Object.assign({}, scriptRes) as ScriptLoadInfo;
script.metadata.grant = ["GM_registerMenuCommand"];
script.code = `return new Promise(resolve=>{
GM_registerMenuCommand("test", ()=>resolve(123));
})`;
const mockSendMessage = vi.fn().mockResolvedValueOnce({ code: 0 });
const mockMessage = {
sendMessage: mockSendMessage,
} as unknown as Message;
// @ts-ignore
const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo);
exec.scriptFunc = compileScript(compileScriptCode(script));
const retPromise = exec.exec();

// 验证 sendMessage 是否被调用
expect(mockSendMessage).toHaveBeenCalled();
expect(mockSendMessage).toHaveBeenCalledTimes(1);

// 获取实际调用的参数
const actualCall = mockSendMessage.mock.calls[0][0];
const actualMenuKey = actualCall.data.params[0];

expect(mockSendMessage).toHaveBeenCalledWith(
expect.objectContaining({
action: "content/runtime/gmApi",
data: {
api: "GM_registerMenuCommand",
params: [actualMenuKey, "test", {}],
runFlag: expect.any(String),
uuid: undefined,
},
})
);
// 模拟点击菜单
exec.emitEvent("menuClick", actualMenuKey, "");
expect(await retPromise).toEqual(123);
});

it("取消注册菜单", async () => {
const script = Object.assign({}, scriptRes) as ScriptLoadInfo;
script.metadata.grant = ["GM_registerMenuCommand", "GM_unregisterMenuCommand"];
script.code = `
let key = GM_registerMenuCommand("test", ()=>key="test");
GM_unregisterMenuCommand(key);
return key;
`;
const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 });
const mockMessage = {
sendMessage: mockSendMessage,
} as unknown as Message;
// @ts-ignore
const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo);
exec.scriptFunc = compileScript(compileScriptCode(script));
const ret = exec.exec();
// 验证 sendMessage 是否被调用
expect(mockSendMessage).toHaveBeenCalled();
expect(mockSendMessage).toHaveBeenCalledTimes(2);

expect(await ret).toEqual(1);
});

it("同id菜单,执行最后一个", async () => {
const script = Object.assign({}, scriptRes) as ScriptLoadInfo;
script.metadata.grant = ["GM_registerMenuCommand"];
script.code = `return new Promise(resolve=>{
GM_registerMenuCommand("test", ()=>resolve(123),{id: "abc"});
GM_registerMenuCommand("test", ()=>resolve(456),{id: "abc"});
})`;
const mockSendMessage = vi.fn().mockResolvedValueOnce({ code: 0 });
const mockMessage = {
sendMessage: mockSendMessage,
} as unknown as Message;
// @ts-ignore
const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo);
exec.scriptFunc = compileScript(compileScriptCode(script));
const retPromise = exec.exec();

// 验证 sendMessage 是否被调用
expect(mockSendMessage).toHaveBeenCalled();
expect(mockSendMessage).toHaveBeenCalledTimes(2);

// 获取实际调用的参数
const actualCall = mockSendMessage.mock.calls[0][0];
const actualMenuKey = actualCall.data.params[0];

expect(mockSendMessage).toHaveBeenCalledWith(
expect.objectContaining({
action: "content/runtime/gmApi",
data: {
api: "GM_registerMenuCommand",
params: [actualMenuKey, "test", {}],
runFlag: expect.any(String),
uuid: undefined,
},
})
);
// 模拟点击菜单
exec.emitEvent("menuClick", actualMenuKey, "");
expect(await retPromise).toEqual(456);
});

it("id生成逻辑", async () => {
const script = Object.assign({}, scriptRes) as ScriptLoadInfo;
script.metadata.grant = ["GM_registerMenuCommand"];
script.code = `
// 自定义id
let obj1 = { id: "abc" };
let id1 = GM_registerMenuCommand("test1", ()=>"test1",obj1);
let id2 = GM_registerMenuCommand("test2", ()=>"test2",obj1);
// 顺序生成的id
let id3 = GM_registerMenuCommand("test3", ()=>"test3");
let id4 = GM_registerMenuCommand("test4", ()=>"test4");
// 不能覆盖顺序
let id5 = GM_registerMenuCommand("test5", ()=>"test5",{id: "3"});
let id6 = GM_registerMenuCommand("test6", ()=>"test6",{id: 3});
let id7 = GM_registerMenuCommand("test7", ()=>"test7");
// 同名菜单-不同的id
let id8 = GM_registerMenuCommand("test7", ()=>"test7");
return { id1, id2, id3, id4, id5, id6, id7, id8 };
`;
const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 });
const mockMessage = {
sendMessage: mockSendMessage,
} as unknown as Message;
// @ts-ignore
const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo);
exec.scriptFunc = compileScript(compileScriptCode(script));
const ret = await exec.exec();
expect(ret).toEqual({ id1: "abc", id2: "abc", id3: 1, id4: 2, id5: "3", id6: 3, id7: 3, id8: 4 });
});
});
109 changes: 67 additions & 42 deletions src/app/service/content/gm_api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { Message, MessageConnect } from "@Packages/message/types";
import type { CustomEventMessage } from "@Packages/message/custom_event_message";
import type { NotificationMessageOption, ScriptMenuItem } from "../service_worker/types";
import { base64ToBlob, strToBase64 } from "@App/pkg/utils/utils";
import type {
GMRegisterMenuCommandParam,
GMUnRegisterMenuCommandParam,
NotificationMessageOption,
ScriptMenuItemOption,
TScriptMenuItemID,
TScriptMenuItemKey,
} from "../service_worker/types";
import { base64ToBlob, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils";
import LoggerCore from "@App/app/logger/core";
import EventEmitter from "eventemitter3";
import GMContext from "./gm_context";
Expand All @@ -21,6 +28,14 @@ export interface IGM_Base {

const integrity = {}; // 仅防止非法实例化

const execEnvInit = (execEnv: GMApi) => {
if (!execEnv.contentEnvKey) {
execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境
execEnv.menuKeyRegistered = new Set();
execEnv.menuIdCounter = 0;
}
};

// GM_Base 定义内部用变量和函数。均使用@protected
// 暂不考虑 Object.getOwnPropertyNames(GM_Base.prototype) 和 ts-morph 脚本生成
class GM_Base implements IGM_Base {
Expand Down Expand Up @@ -440,52 +455,59 @@ export default class GMApi extends GM_Base {
_GM_cookie(this, action, details, done);
}

menuMap: Map<number, string> | undefined;
// 已注册的「菜单唯一键」集合,用于去重与解除绑定。
// 唯一键格式:{contentEnvKey}.t{注册ID},由 execEnvInit() 建立/维护。
menuKeyRegistered: Set<string> | undefined;

// 自动产生的菜单 ID 累计器(仅在未提供 options.id 时使用)。
// 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。
menuIdCounter: number | undefined;

// 内容脚本执行环境识别符,用于区分 mainframe / subframe 等环境并作为 menu key 的命名空间。
// 由 execEnvInit() 以 randomMessageFlag() 生成,避免跨 frame 的 ID 碰撞。
// (同一环境跨脚本也不一样)
contentEnvKey: string | undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以使用 runFlag ,不过另外再生成一个也没问题,语义上不同,只是提一下有 runFlag 也可以用于区分不同的环境/脚本


@GMContext.API({ alias: "GM.registerMenuCommand" })
GM_registerMenuCommand(
name: string,
listener: (inputValue?: any) => void,
options_or_accessKey?: ScriptMenuItem["options"] | string
): number {
if (!this.menuMap) {
this.menuMap = new Map();
}
if (typeof options_or_accessKey === "object") {
const option: ScriptMenuItem["options"] = options_or_accessKey;
// 如果是对象,并且有id属性,则直接使用id
if (option.id && this.menuMap.has(option.id)) {
// 如果id存在,则直接使用
this.EE.removeAllListeners("menuClick:" + option.id);
this.EE.addListener("menuClick:" + option.id, listener);
this.sendMessage("GM_registerMenuCommand", [option.id, name, option]);
return option.id;
}
options_or_accessKey?: ScriptMenuItemOption | string
): TScriptMenuItemID {
execEnvInit(this);
// 浅拷贝避免修改/共用参数
const options = (
typeof options_or_accessKey === "string"
? { accessKey: options_or_accessKey }
: options_or_accessKey
? { ...options_or_accessKey }
: {}
) as ScriptMenuItemOption;
let providedId: string | number | undefined = options.id;
delete options.id; // id不直接储存在options (id 影响 groupKey 操作)
if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id
const ret = providedId as TScriptMenuItemID;
providedId = `t${providedId}`; // 见 TScriptMenuItemID 注释
providedId = `${this.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释
const menuKey = providedId; // menuKey为唯一键:{环境识别符}.t{注册ID}
// 检查之前有否注册
if (menuKey && this.menuKeyRegistered!.has(menuKey)) {
// 有注册过,先移除 listeners
this.EE.removeAllListeners("menuClick:" + menuKey);
} else {
options_or_accessKey = { accessKey: options_or_accessKey };
let flag = 0;
this.menuMap.forEach((val, menuId) => {
if (val === name) {
flag = menuId;
}
});
if (flag) {
return flag;
}
// 没注册过,先记录一下
this.menuKeyRegistered!.add(menuKey);
}
this.eventId += 1;
const id = this.eventId;
options_or_accessKey.id = id;
this.menuMap.set(id, name);
this.EE.addListener("menuClick:" + id, listener);
this.sendMessage("GM_registerMenuCommand", [id, name, options_or_accessKey]);
return id;
this.EE.addListener("menuClick:" + menuKey, listener);
// 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定)
this.sendMessage("GM_registerMenuCommand", [menuKey, name, options] as GMRegisterMenuCommandParam);
return ret;
}

@GMContext.API({
depend: ["GM_registerMenuCommand"],
})
CAT_registerMenuInput(...args: Parameters<GMApi["GM_registerMenuCommand"]>): number {
CAT_registerMenuInput(...args: Parameters<GMApi["GM_registerMenuCommand"]>): TScriptMenuItemID {
return this.GM_registerMenuCommand(...args);
}

Expand Down Expand Up @@ -543,13 +565,16 @@ export default class GMApi extends GM_Base {
}

@GMContext.API({ alias: "GM.unregisterMenuCommand" })
GM_unregisterMenuCommand(id: number): void {
if (!this.menuMap) {
this.menuMap = new Map();
GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void {
if (!this.contentEnvKey) {
return;
}
this.menuMap.delete(id);
this.EE.removeAllListeners("menuClick:" + id);
this.sendMessage("GM_unregisterMenuCommand", [id]);
let menuKey = `t${menuId}`; // 见 TScriptMenuItemID 注释
menuKey = `${this.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释
this.menuKeyRegistered!.delete(menuKey);
this.EE.removeAllListeners("menuClick:" + menuKey);
// 发送至 service worker 处理(唯一键)
this.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam);
}

@GMContext.API({
Expand Down
16 changes: 11 additions & 5 deletions src/app/service/queue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Script, SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts";
import type { InstallSource, ScriptMenuItem } from "./service_worker/types";
import type {
InstallSource,
ScriptMenuItemOption,
TScriptMenuItemKey,
TScriptMenuItemName,
} from "./service_worker/types";
import type { Subscribe } from "../repo/subscribe";

export type TInstallScriptParams = {
Expand Down Expand Up @@ -29,17 +34,18 @@ export type TScriptValueUpdate = { script: Script };

export type TScriptMenuRegister = {
uuid: string;
id: number;
name: string;
options?: ScriptMenuItem["options"];
key: TScriptMenuItemKey;
name: TScriptMenuItemName;
options?: Omit<ScriptMenuItemOption, "id">;
tabId: number;
frameId?: number;
documentId?: string;
};

export type TScriptMenuUnregister = {
id: number;
key: TScriptMenuItemKey;
uuid: string;
tabId: number;
frameId?: number;
documentId?: string;
};
14 changes: 11 additions & 3 deletions src/app/service/service_worker/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import type {
ScriptMenuItem,
SearchType,
TBatchUpdateListAction,
TScriptMenuItemKey,
} from "./types";
import { Client } from "@Packages/message/client";
import type { MessageSend } from "@Packages/message/types";
import type { ExtMessageSender, MessageSend } from "@Packages/message/types";
import type PermissionVerify from "./permission_verify";
import { type UserConfirm } from "./permission_verify";
import { type FileSystemType } from "@Packages/filesystem/factory";
Expand Down Expand Up @@ -277,6 +278,13 @@ export type GetPopupDataRes = {
backScriptList: ScriptMenu[];
};

export type MenuClickParams = {
uuid: string;
key: TScriptMenuItemKey;
sender: ExtMessageSender;
inputValue?: any;
};

export class PopupClient extends Client {
constructor(msgSender: MessageSend) {
super(msgSender, "serviceWorker/popup");
Expand All @@ -289,14 +297,14 @@ export class PopupClient extends Client {
menuClick(uuid: string, data: ScriptMenuItem, inputValue?: any) {
return this.do("menuClick", {
uuid,
id: data.id,
key: data.key,
inputValue,
sender: {
tabId: data.tabId,
frameId: data.frameId,
documentId: data.documentId,
},
});
} as MenuClickParams);
}
}

Expand Down
Loading
Loading