Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
}

incr(key: string, increase: number): Promise<number> {
Expand Down
67 changes: 66 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,67 @@ 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);
Comment on lines +226 to +227
Copy link

Copilot AI Oct 9, 2025

Choose a reason for hiding this comment

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

在测试代码中使用 @ts-ignore 并不理想。应该通过正确的类型定义或类型断言来解决类型问题,例如使用 as any 或创建合适的 mock 类型。

Copilot uses AI. Check for mistakes.
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);
Comment on lines +267 to +268
Copy link

Copilot AI Oct 9, 2025

Choose a reason for hiding this comment

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

在测试代码中使用 @ts-ignore 并不理想。应该通过正确的类型定义或类型断言来解决类型问题,例如使用 as any 或创建合适的 mock 类型。

Suggested change
// @ts-ignore
const exec = new ExecScript(script, "content", mockMessage, nilFn, envInfo);
const exec = new ExecScript(script, "content", mockMessage as any, nilFn, envInfo);

Copilot uses AI. Check for mistakes.
exec.scriptFunc = compileScript(compileScriptCode(script));
const ret = exec.exec();
// 验证 sendMessage 是否被调用
expect(mockSendMessage).toHaveBeenCalled();
expect(mockSendMessage).toHaveBeenCalledTimes(2);

expect(await ret).toEqual(1);
});
});
109 changes: 66 additions & 43 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;

@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,14 @@ export default class GMApi extends GM_Base {
}

@GMContext.API({ alias: "GM.unregisterMenuCommand" })
GM_unregisterMenuCommand(id: number): void {
if (!this.menuMap) {
this.menuMap = new Map();
}
this.menuMap.delete(id);
this.EE.removeAllListeners("menuClick:" + id);
this.sendMessage("GM_unregisterMenuCommand", [id]);
GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void {
execEnvInit(this);
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
20 changes: 14 additions & 6 deletions src/app/service/service_worker/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ import FileSystemFactory from "@Packages/filesystem/factory";
import type FileSystem from "@Packages/filesystem/filesystem";
import { isWarpTokenError } from "@Packages/filesystem/error";
import { joinPath } from "@Packages/filesystem/utils";
import type { EmitEventRequest, MessageRequest, NotificationMessageOption, Request } from "./types";
import type {
EmitEventRequest,
GMRegisterMenuCommandParam,
GMUnRegisterMenuCommandParam,
MessageRequest,
NotificationMessageOption,
Request,
} from "./types";
import type { TScriptMenuRegister, TScriptMenuUnregister } from "../queue";
import { BrowserNoSupport, notificationsUpdate } from "./utils";
import i18n from "@App/locales/locales";
Expand Down Expand Up @@ -783,11 +790,11 @@ export default class GMApi {

@PermissionVerify.API({ alias: ["CAT_registerMenuInput"] })
GM_registerMenuCommand(request: Request, sender: IGetSender) {
const [id, name, options] = request.params;
const [key, name, options] = request.params as GMRegisterMenuCommandParam;
// 触发菜单注册, 在popup中处理
this.mq.emit<TScriptMenuRegister>("registerMenuCommand", {
uuid: request.script.uuid,
id,
key,
name,
options,
tabId: sender.getSender()?.tab?.id || -1,
Expand All @@ -798,13 +805,14 @@ export default class GMApi {

@PermissionVerify.API({ alias: ["CAT_unregisterMenuInput"] })
GM_unregisterMenuCommand(request: Request, sender: IGetSender) {
const [id] = request.params;
const [key] = request.params as GMUnRegisterMenuCommandParam;
// 触发菜单取消注册, 在popup中处理
this.mq.emit<TScriptMenuUnregister>("unregisterMenuCommand", {
uuid: request.script.uuid,
id: id,
key,
tabId: sender.getSender()?.tab?.id || -1,
frameId: sender.getSender()?.frameId,
documentId: sender.getSender()?.documentId,
});
}

Expand Down Expand Up @@ -902,7 +910,7 @@ export default class GMApi {
return tabData || {};
})
.then((data) => {
return data[sender.getExtMessageSender().tabId];
return data![sender.getExtMessageSender().tabId];
});
}

Expand Down
Loading
Loading