diff --git a/src/app/cache.ts b/src/app/cache.ts index e4b40e87d..cef2fa771 100644 --- a/src/app/cache.ts +++ b/src/app/cache.ts @@ -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 { diff --git a/src/app/service/content/gm_api.test.ts b/src/app/service/content/gm_api.test.ts index 1986dcf16..6240dbd4b 100644 --- a/src/app/service/content/gm_api.test.ts +++ b/src/app/service/content/gm_api.test.ts @@ -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 = () => {}; @@ -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); + 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); + }); +}); diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index 99c2dafbd..bdc29c0be 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -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"; @@ -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 { @@ -440,52 +455,59 @@ export default class GMApi extends GM_Base { _GM_cookie(this, action, details, done); } - menuMap: Map | undefined; + // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 + // 唯一键格式:{contentEnvKey}.t{注册ID},由 execEnvInit() 建立/维护。 + menuKeyRegistered: Set | 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): number { + CAT_registerMenuInput(...args: Parameters): TScriptMenuItemID { return this.GM_registerMenuCommand(...args); } @@ -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({ diff --git a/src/app/service/queue.ts b/src/app/service/queue.ts index ea10b7fda..c224abc67 100644 --- a/src/app/service/queue.ts +++ b/src/app/service/queue.ts @@ -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 = { @@ -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; tabId: number; frameId?: number; documentId?: string; }; export type TScriptMenuUnregister = { - id: number; + key: TScriptMenuItemKey; uuid: string; tabId: number; frameId?: number; + documentId?: string; }; diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index cda1a07c6..babc3eb84 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -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"; @@ -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"); @@ -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); } } diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index dffac4065..a19fc99ca 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -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"; @@ -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("registerMenuCommand", { uuid: request.script.uuid, - id, + key, name, options, tabId: sender.getSender()?.tab?.id || -1, @@ -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("unregisterMenuCommand", { uuid: request.script.uuid, - id: id, + key, tabId: sender.getSender()?.tab?.id || -1, frameId: sender.getSender()?.frameId, + documentId: sender.getSender()?.documentId, }); } @@ -902,7 +910,7 @@ export default class GMApi { return tabData || {}; }) .then((data) => { - return data[sender.getExtMessageSender().tabId]; + return data![sender.getExtMessageSender().tabId]; }); } diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index b2943b0d4..3a3fe8a3a 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -1,9 +1,8 @@ import { type IMessageQueue } from "@Packages/message/message_queue"; import { type Group } from "@Packages/message/server"; -import type { ExtMessageSender } from "@Packages/message/types"; import { type RuntimeService } from "./runtime"; -import type { ScriptMenu } from "./types"; -import type { GetPopupDataReq, GetPopupDataRes } from "./client"; +import type { ScriptMenu, ScriptMenuItem, TPopupScript } from "./types"; +import type { GetPopupDataReq, GetPopupDataRes, MenuClickParams } from "./client"; import { cacheInstance } from "@App/app/cache"; import type { Script, ScriptDAO } from "@App/app/repo/scripts"; import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts"; @@ -19,18 +18,62 @@ import { getStorageName, getCurrentTab } from "@App/pkg/utils/utils"; import type { SystemConfig } from "@App/pkg/config/config"; import { CACHE_KEY_TAB_SCRIPT } from "@App/app/cache_key"; import { timeoutExecution } from "@App/pkg/utils/timer"; +import { v5 as uuidv5 } from "uuid"; import { getCombinedMeta } from "./utils"; type TxUpdateScriptMenuCallback = ( result: ScriptMenu[] ) => Promise | ScriptMenu[] | undefined; +// 以 tabId 为 key 的「执行次数」快取(字串形式存放),供 badge 显示使用。 const runCountMap = new Map(); + +// 以 tabId 为 key 的「脚本数量」快取,供 badge 显示使用。 const scriptCountMap = new Map(); + +// 已设定过 badge 的 tabId 集合;切换到「不显示数字」时用来清除既有 badge。 const badgeShownSet = new Set(); +// 用于 timeoutExecution 的唯一前缀 key(含随机片段),避免不同 tab 的排程互相覆盖。 const cIdKey = `(cid_${Math.random()})`; +// uuidv5 的命名空间:用来稳定生成 groupKey,将「相同性质」的 menu 合并显示。 +const groupKeyNS = "43b9b9b1-75b7-4054-801c-1b0ad6b6b07b"; + +// -------------------------------------------------------------------------------------------------- + +// Chrome 限制:contextMenu 的 id 必须稳定不可频繁改变 +// (例如:id-1 一次放在 index0,接著 removeAll 后又放到 index8,再 removeAll 又放到 index4) +// 推测是 Chrome 内部程式码没有预期到 menu id 大量增加/删除/跳跃 +// 因此使用 chrome.contextMenus.create 建立新 id 的 menu item 时会发生冲突 +// 如果 tab 切换,id 若跟随 script.uuid 变化,冲突更严重 +// 会导致菜单项目可能无法正确显示 +// 解法:整个浏览器共用一批固定的 uuidv4 作为 contextMenu 项目 id(不分 tab) + +// SC 内部 id → Chrome 显示 id 的映射表(用于把 parentId/子项关联到稳定的显示 id)。 +const contextMenuConvMap1 = new Map(); +// Chrome 显示 id → SC 内部 id 的反向映射表(用于点击事件回推原始 SC id)。 +const contextMenuConvMap2 = new Map(); + +// -------------------------------------------------------------------------------------------------- + +let lastActiveTabId = 0; +let menuRegisterNeedUpdate = false; + +// -------------------------------------------------------------------------------------------------- + +// menuRegister 用来保存「当前 Tab」的 menu 状态。 +// 其中会包含 mainframe 和 subframe 的项目。 +// 最后会再透过 groupKey 做整合显示。 + +// 每个 tab 的脚本菜单暂存;key 为 `${tabId}.${uuid}`,值为该脚本在该 tab 的菜单项(含 mainframe/subframe)。 +const menuRegister = new Map(); + +// -------------------------------------------------------------------------------------------------- + +// 串接中的更新承诺:序列化 genScriptMenu 执行,避免并行重建 contextMenu。 +let contextMenuUpdatePromise = Promise.resolve(); + // 处理popup页面的数据 export class PopupService { constructor( @@ -41,116 +84,231 @@ export class PopupService { private systemConfig: SystemConfig ) {} - genScriptMenuByTabMap(menu: ScriptMenu[]) { - let n = 0; + // 将 ScriptMenu[] 转为 Chrome contextMenus.CreateProperties[];同一 groupKey 仅保留一个实际显示项。 + genScriptMenuByTabMap(menuEntries: chrome.contextMenus.CreateProperties[], menu: ScriptMenu[]) { for (const { uuid, name, menus } of menu) { - // 如果是带输入框的菜单则不在页面内注册 - const nonInputMenus = menus.filter((item) => !item.options?.inputType); - // 创建脚本菜单 - if (nonInputMenus.length) { - n += nonInputMenus.length; - chrome.contextMenus.create({ - id: `scriptMenu_${uuid}`, + const subMenuEntries = [] as chrome.contextMenus.CreateProperties[]; + let withMenuItem = false; + const groupKeys = new Map(); + for (const { name, options, groupKey } of menus) { + if (options?.inputType) continue; // 如果是带输入框的菜单则不在页面内注册 + if (groupKeys.has(groupKey)) continue; + groupKeys.set(groupKey, name); + } + for (const [groupKey, name] of groupKeys) { + if (!name) continue; // 日后再调整 name 为空的情况 + // 创建菜单 + const menuUid = `scriptMenu_menu_${uuid}_${groupKey}`; + const createProperties = { + id: menuUid, title: name, contexts: ["all"], - parentId: "scriptMenu", - }); - nonInputMenus.forEach((menu) => { - // 创建菜单 - chrome.contextMenus.create({ - id: `scriptMenu_menu_${uuid}_${menu.id}`, - title: menu.name, + parentId: `scriptMenu_${uuid}`, // 上层是 `scriptMenu_${uuid}` + } as chrome.contextMenus.CreateProperties; + withMenuItem = true; // 日后或引入菜单分隔线的设计。 withMenuItem = true 表示实际菜单选项有。 + subMenuEntries.push(createProperties); + } + if (withMenuItem) { + // 创建脚本菜单 + menuEntries.push( + { + id: `scriptMenu_${uuid}`, + title: name, contexts: ["all"], - parentId: `scriptMenu_${uuid}`, - }); - }); + parentId: "scriptMenu", + }, + ...subMenuEntries + ); } } - return n; } // 生成chrome菜单 - async genScriptMenu(tabId: number) { - // 移除之前所有的菜单 - await chrome.contextMenus.removeAll(); + async genScriptMenu() { + // 使用简单 Promise chain 避免同一个程序同时跑 + contextMenuUpdatePromise = contextMenuUpdatePromise + .then(async () => { + const tabId = lastActiveTabId; + if (tabId === 0) return; + const menuEntries = [] as chrome.contextMenus.CreateProperties[]; + const displayType = await this.systemConfig.getScriptMenuDisplayType(); + if (displayType === "all") { + const [menu, backgroundMenu] = await Promise.all([this.getScriptMenu(tabId), this.getScriptMenu(-1)]); + if (menu?.length) this.genScriptMenuByTabMap(menuEntries, menu); + if (backgroundMenu?.length) this.genScriptMenuByTabMap(menuEntries, backgroundMenu); // 后台脚本的菜单 + if (menuEntries.length > 0) { + // 创建根菜单 + // 若有子项才建立根节点「ScriptCat」,避免出现空的顶层菜单。 + menuEntries.unshift({ + id: "scriptMenu", + title: "ScriptCat", + contexts: ["all"], + }); + } + } - if ((await this.systemConfig.getScriptMenuDisplayType()) !== "all") { - return; - } + // 移除之前所有的菜单 + await chrome.contextMenus.removeAll(); + contextMenuConvMap1.clear(); + contextMenuConvMap2.clear(); - const [menu, backgroundMenu] = await Promise.all([this.getScriptMenu(tabId), this.getScriptMenu(-1)]); - if (!menu.length && !backgroundMenu.length) { - return; - } - let n = 0; - // 创建根菜单 - chrome.contextMenus.create({ - id: "scriptMenu", - title: "ScriptCat", - contexts: ["all"], + let i = 0; + for (const menuEntry of menuEntries) { + // 菜单项目用的共通 uuid. 不会随 tab 切换或换页换iframe载入等行为改变。稳定id + // 稳定显示 id:即使 removeAll 重建,显示 id 仍保持一致以规避 Chrome 的不稳定行为。 + const menuDisplayId = `${groupKeyNS}-${100000 + i}`; + // 把 SC管理用id 换成 menu显示用id + if (menuEntry.id) { + // 建立 SC id ↔ 显示 id 的双向映射:parentId/点击回推都依赖此映射。 + contextMenuConvMap1.set(menuEntry.id!, menuDisplayId); // 用于parentId转换menuDisplayId + contextMenuConvMap2.set(menuDisplayId, menuEntry.id!); // 用于menuDisplayId转换成SC管理用id + menuEntry.id = menuDisplayId; + } + if (menuEntry.parentId) { + menuEntry.parentId = contextMenuConvMap1.get(menuEntry.parentId) || menuEntry.parentId; + } + + i++; + // 由于使用旧id,旧的内部context menu item应会被重用因此不会造成记忆体失控。 + // (推论内部有cache机制,即使removeAll也是有残留) + chrome.contextMenus.create(menuEntry, () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.contextMenus.create:", lastError.message); + } + }); + } + }) + .catch(console.warn); + } + + // 在多次本地记录操作后,只需要执行一次有锁记录更新。不用更新回传 false + // 将「无锁的本地 menuRegister」同步回「有锁的快取」;若无实质变更则不发布更新事件。 + async syncMenuCommandsToSessionStore(tabId: number, uuid: string): Promise { + let retUpdated = false; + await this.txUpdateScriptMenu(tabId, async (data) => { + const script = data.find((item) => item.uuid === uuid); + if (script && menuRegisterNeedUpdate) { + menuRegisterNeedUpdate = false; + retUpdated = true; + // 本地纪录(最新)复制到外部存取(有锁) + script.menus = [...(menuRegister.get(`${tabId}.${uuid}`) || [])]; + } + return data; }); - if (menu) { - n += this.genScriptMenuByTabMap(menu); + if (retUpdated) { + this.mq.publish("popupMenuRecordUpdated", { tabId, uuid }); } - // 后台脚本的菜单 - if (backgroundMenu) { - n += this.genScriptMenuByTabMap(backgroundMenu); - } - if (n === 0) { - // 如果没有菜单,删除菜单 - await chrome.contextMenus.remove("scriptMenu"); + return retUpdated; + } + + // 标记需要同步后,若成功写回快取,再触发实际菜单重建(避免多次小变更重复重建)。 + async onMenuCommandsChanged(tabId: number, uuid: string) { + menuRegisterNeedUpdate = true; + const didTxRecordUpdate = await this.syncMenuCommandsToSessionStore(tabId, uuid); + if (didTxRecordUpdate) { + // 更新数据后再更新菜单 + await this.updateScriptMenu(tabId); } } async registerMenuCommand(message: TScriptMenuRegister) { + // GM_registerMenuCommand 是同步函数。 + // 所以流程是:先在 popup.ts 即时更新「无锁的记录」,再回写到「有锁记录」交给 Popup/App.tsx。 + // 这样可以避免新增/删除操作的次序冲突。 + // 外部(popup.tsx)只会读取 menu items,不会直接修改(若要修改,必须透过 popup.ts)。 + // 给脚本添加菜单 - await this.txUpdateScriptMenu(message.tabId, async (data) => { - const script = data.find((item) => item.uuid === message.uuid); - if (script) { - const menu = script.menus.find((item) => item.id === message.id); - if (!menu) { - script.menus.push({ - id: message.id, - name: message.name, - options: message.options, - tabId: message.tabId, - frameId: message.frameId, - documentId: message.documentId, - }); - } else { - // 存在修改信息 - menu.name = message.name; - menu.options = message.options; - } + + const { key, name, uuid, tabId } = message; // 唯一键, 项目显示名字, 脚本uuid + // message.key是唯一的。 即使在同一tab里的mainframe subframe也是不一样 + + // 以 `${tabId}.${uuid}` 作为隔离命名空间,避免跨分页/框架互相干扰。 + const mrKey = `${tabId}.${uuid}`; // 防止多分页间的registerMenuCommand互相影响 + + let menus = menuRegister.get(mrKey) as ScriptMenuItem[]; + if (!menus) { + menus = [] as ScriptMenuItem[]; + menuRegister.set(mrKey, menus); + } + + // 以 options+name 生成稳定 groupKey:相同语义项目在 UI 只呈现一次,但可同时触发多个来源(frame)。 + // groupKey 用来表示「相同性质的项目」,允许重叠。 + // 例如 subframe 和 mainframe 创建了相同的 menu item,显示时只会出现一个。 + // 但点击后,两边都会执行。 + // 目的只是整理显示,实际上内部还是存有多笔 entry(分别记录不同的 frameId 和 id)。 + const groupKey = uuidv5( + message.options?.inputType + ? JSON.stringify({ ...message.options, autoClose: undefined, id: undefined, name: name }) + : `${name}\n${message.options?.accessKey || ""}`, + groupKeyNS + ); + + let found = false; + for (const item of menus) { + if (item.key === key) { + found = true; + // 存在修改信息 + item.name = name; + item.options = { ...message.options }; + item.groupKey = groupKey; + break; } - return data; - }); - // 更新数据后再更新菜单 - await this.updateScriptMenu(); + } + if (!found) { + const entry = { + groupKey, + key: key, // unique primary key + name: name, + options: message.options, + tabId: tabId, // fix + frameId: message.frameId, // fix with unique key + documentId: message.documentId, // fix with unique key + }; + menus.push(entry); + } + // 更新有锁记录 + await this.onMenuCommandsChanged(tabId, uuid); } - async unregisterMenuCommand({ id, uuid, tabId }: TScriptMenuUnregister) { - await this.txUpdateScriptMenu(tabId, async (data) => { - // 删除脚本菜单 - const script = data.find((item) => item.uuid === uuid); - if (script) { - script.menus = script.menus.filter((item) => item.id !== id); + async unregisterMenuCommand({ key, uuid, tabId }: TScriptMenuUnregister) { + const mrKey = `${tabId}.${uuid}`; + + let menus = menuRegister.get(mrKey) as ScriptMenuItem[]; + if (!menus) { + menus = [] as ScriptMenuItem[]; + menuRegister.set(mrKey, menus); + } + + for (let i = 0, l = menus.length; i < l; i++) { + if (menus[i].key === key) { + menus.splice(i, 1); + break; } - return data; - }); - await this.updateScriptMenu(); + } + // 更新有锁记录 + await this.onMenuCommandsChanged(tabId, uuid); } - async updateScriptMenu() { - // 获取当前页面并更新菜单 - const tab = await getCurrentTab(); - // 生成菜单 - if (tab?.id) { - await this.genScriptMenu(tab.id); + async updateScriptMenu(tabId: number) { + if (tabId !== lastActiveTabId) return; // 其他页面的指令,不理 + + // 注意:不要使用 getCurrentTab()。 + // 因为如果使用者切换到其他应用(如 Excel/Photoshop),网页仍可能触发 menu 的注册/解除操作。 + // 若此时用 getCurrentTab(),就无法正确更新右键选单。 + + // 检查一下 tab的有效性 + // 仅针对目前 lastActiveTabId 进行检查与更新,避免误在非当前 tab 重建菜单。 + const tab = await chrome.tabs.get(lastActiveTabId); + if (tab && !tab.frozen && tab.active && !tab.discarded && tab.lastAccessed) { + // 更新菜单 / 生成菜单 + await this.genScriptMenu(); } } - scriptToMenu(script: Script): ScriptMenu { + // 将 Script 转为 ScriptMenu 并初始化其在该 tab 的菜单暂存(menus 空阵列、计数归零)。 + scriptToMenu(script: Script, tabId: number): ScriptMenu { + menuRegister.set(`${tabId}.${script.uuid}`, []); return { uuid: script.uuid, name: script.name, @@ -169,9 +327,10 @@ export class PopupService { // 获取popup页面数据 async getPopupData(req: GetPopupDataReq): Promise { + const { url, tabId } = req; const [matchingResult, runScripts, backScriptList] = await Promise.all([ - this.runtime.getPageScriptMatchingResultByUrl(req.url, true, true), - this.getScriptMenu(req.tabId), + this.runtime.getPageScriptMatchingResultByUrl(url, true, true), + this.getScriptMenu(tabId), this.getScriptMenu(-1), ]); @@ -180,6 +339,7 @@ export class PopupService { const scripts = await this.scriptDAO.gets(uuids); // 与运行时脚本进行合并 + // 以已运行脚本建立快取(uuid→ScriptMenu),供后续合并与覆盖状态。 const runMap = new Map(runScripts.map((script) => [script.uuid, script])); // 合并后结果 const scriptMenuMap = new Map(); @@ -201,11 +361,13 @@ export class PopupService { if (script.selfMetadata) { script.metadata = getCombinedMeta(script.metadata, script.selfMetadata); } - run = this.scriptToMenu(script); + run = this.scriptToMenu(script, tabId); run.isEffective = o.effective!; } scriptMenuMap.set(uuid, run); } + + // 将未匹配当前 url 但仍在运行的脚本,附加到清单末端,避免使用者找不到其菜单。 // 把运行了但是不在匹配中的脚本加入到菜单的最后 (因此 runMap 和 scriptMenuMap 分开成两个变数) for (const script of runScripts) { // 把运行了但是不在匹配中的脚本加入菜单 @@ -215,7 +377,7 @@ export class PopupService { } const scriptMenu = [...scriptMenuMap.values()]; // 检查是否在黑名单中 - const isBlacklist = this.runtime.isUrlBlacklist(req.url); + const isBlacklist = this.runtime.isUrlBlacklist(url); // 后台脚本只显示开启或者运行中的脚本 return { isBlacklist, scriptList: scriptMenu, backScriptList }; } @@ -226,7 +388,8 @@ export class PopupService { } // 事务更新脚本菜单 - txUpdateScriptMenu(tabId: number, callback: TxUpdateScriptMenuCallback) { + // 以快取层的事务操作安全更新某 tab 的 ScriptMenu 阵列,避免竞态条件。 + txUpdateScriptMenu(tabId: number, callback: TxUpdateScriptMenuCallback): Promise { const cacheKey = `${CACHE_KEY_TAB_SCRIPT}${tabId}`; return cacheInstance.tx(cacheKey, (menu) => callback(menu || [])); } @@ -234,6 +397,7 @@ export class PopupService { async addScriptRunNumber({ tabId, frameId, scripts }: { tabId: number; frameId: number; scripts: Script[] }) { // 设置数据 return await this.txUpdateScriptMenu(tabId, async (data) => { + // 特例:frameId 为 0/未提供时,重置当前 tab 的计数资料(视为页面重新载入)。 if (!frameId) { data = []; } @@ -241,12 +405,13 @@ export class PopupService { scripts.forEach((script) => { const scriptMenu = data.find((item) => item.uuid === script.uuid); if (scriptMenu) { + // runNum:累计总执行次数;runNumByIframe:仅 iframe 执行次数(用于精细显示/统计)。 scriptMenu.runNum = (scriptMenu.runNum || 0) + 1; if (frameId) { scriptMenu.runNumByIframe = (scriptMenu.runNumByIframe || 0) + 1; } } else { - const item = this.scriptToMenu(script); + const item = this.scriptToMenu(script, tabId); item.isEffective = true; item.runNum = 1; if (frameId) { @@ -265,6 +430,7 @@ export class PopupService { }); } + // 处理「非页面型(background)」脚本的安装/启用/删除/状态变更,并同步其菜单至 tabId = -1 的命名空间。 dealBackgroundScriptInstall() { // 处理后台脚本 this.mq.subscribe("installScript", async (data) => { @@ -283,7 +449,7 @@ export class PopupService { const scriptMenu = menu.find((item) => item.uuid === script.uuid); // 加入菜单 if (!scriptMenu) { - const item = this.scriptToMenu(script); + const item = this.scriptToMenu(script, -1); menu.push(item); } return menu; @@ -303,7 +469,7 @@ export class PopupService { if (script.status === SCRIPT_STATUS_ENABLE) { // 加入菜单 if (index === -1) { - const item = this.scriptToMenu(script); + const item = this.scriptToMenu(script, -1); menu.push(item); } } else { @@ -343,33 +509,26 @@ export class PopupService { }); } - async menuClick({ - uuid, - id, - sender, - inputValue, - }: { - uuid: string; - id: number; - sender: ExtMessageSender; - inputValue?: any; - }) { + // 触发目标 tab/frame 的「menuClick」事件;key 为菜单唯一键以定位对应 listener。 + async menuClick({ uuid, key, sender, inputValue }: MenuClickParams) { // 菜单点击事件 await this.runtime.emitEventToTab(sender, { uuid, event: "menuClick", - eventId: id.toString(), + eventId: `${key}`, data: inputValue, }); return true; } - async updateBadgeIcon(tabId: number | undefined = -1) { - if (tabId < 0) { - const tab = await getCurrentTab(); - tabId = tab?.id; - } - if (typeof tabId !== "number") return; + async updateBadgeIcon() { + // badge 显示数字的策略: + // - script_count:显示脚本数 + // - run_count:显示执行次数 + // - 其他:不显示数字 + // 如果切换为「不显示数字」模式,需要清空已经显示过的 badge。 + const tabId = lastActiveTabId; + if (!tabId) return; const badgeNumberType: string = await this.systemConfig.getBadgeNumberType(); let map: Map | undefined; if (badgeNumberType === "script_count") { @@ -391,6 +550,7 @@ export class PopupService { if (typeof text !== "string") return; const backgroundColor = await this.systemConfig.getBadgeBackgroundColor(); const textColor = await this.systemConfig.getBadgeTextColor(); + // 标记此 tab 的 badge 已设定,便于后续在「不显示」模式时进行清理。 badgeShownSet.add(tabId); timeoutExecution( `${cIdKey}-tabId#${tabId}`, @@ -431,6 +591,10 @@ export class PopupService { } runCountMap.delete(tabId); scriptCountMap.delete(tabId); + const mrKeys = [...menuRegister.keys()].filter((key) => key.startsWith(`${tabId}.`)); + for (const key of mrKeys) { + menuRegister.delete(key); + } // 清理数据tab关闭需要释放的数据 this.txUpdateScriptMenu(tabId, async (scripts) => { for (const { uuid } of scripts) { @@ -447,6 +611,15 @@ export class PopupService { }); }); // 监听页面切换加载菜单 + // 进程启动时可能尚未触发 onActivated:补一次初始化以建立当前 tab 的菜单与 badge。 + getCurrentTab().then((tab) => { + // 处理载入时未触发 chrome.tabs.onActivated 的情况 + if (!lastActiveTabId && tab?.id) { + lastActiveTabId = tab.id; + this.genScriptMenu(); + this.updateBadgeIcon(); + } + }); chrome.tabs.onActivated.addListener((activeInfo) => { const lastError = chrome.runtime.lastError; if (lastError) { @@ -454,8 +627,11 @@ export class PopupService { // 没有 tabId 资讯,无法加载菜单 return; } - this.genScriptMenu(activeInfo.tabId); - this.updateBadgeIcon(activeInfo.tabId); + lastActiveTabId = activeInfo.tabId; + // 目前设计:subframe 和 mainframe 的 contextMenu 是共用的。 + // 换句话说,subframe 的右键菜单可以执行 mainframe 的选项,反之亦然。 + this.genScriptMenu(); + this.updateBadgeIcon(); }); // chrome.tabs.onUpdated.addListener((tabId, _changeInfo, _tab) => { // const lastError = chrome.runtime.lastError; @@ -483,9 +659,15 @@ export class PopupService { // 出现错误不处理chrome菜单点击 return; } - const menuIds = `${info.menuItemId}`.split("_"); + // 先以显示 id 逆向查回 SC 内部 id(防 Chrome 映射差异),再依 `scriptMenu_menu_${uuid}_${groupKey}` 解析来源。 + const id1 = info.menuItemId; + const id2 = contextMenuConvMap2.get(`${id1}`) || id1; + const id9 = id2; + // scriptMenu_menu_${uuid}_${groupKey}` + if (!`${id9}`.startsWith("scriptMenu_menu_")) return; // 不处理非 scriptMenu_menu_ 开首的 + const menuIds = `${id9}`.split("_"); if (menuIds.length === 4) { - const [, , uuid, id] = menuIds; + const [, , uuid, groupKey] = menuIds; // 寻找menu信息 const menu = await this.getScriptMenu(tab!.id!); let script = menu.find((item) => item.uuid === uuid); @@ -497,19 +679,22 @@ export class PopupService { bgscript = true; } if (script) { - const menuItem = script.menus.find((item) => item.id === parseInt(id, 10)); - if (menuItem) { - await this.menuClick({ - uuid: script.uuid, - id: menuItem.id, - sender: { - tabId: bgscript ? -1 : tab!.id!, - frameId: menuItem.frameId || 0, - documentId: menuItem.documentId || "", - }, - }); - return; - } + // 仅触发「非输入型」且 groupKey 相符的项目;同 groupKey 可能代表多个 frame 来源,一次性全部触发。 + const menuItems = script.menus.filter((item) => item.groupKey === groupKey && !item.options?.inputType); + await Promise.allSettled( + menuItems.map((menuItem) => + this.menuClick({ + uuid: script.uuid, + key: menuItem.key, + sender: { + tabId: bgscript ? -1 : tab!.id!, + frameId: menuItem.frameId || 0, + documentId: menuItem.documentId || "", + }, + } as MenuClickParams) + ) + ); + return; } } }); @@ -518,12 +703,15 @@ export class PopupService { // runCountMap.clear(); // 监听运行次数 + // 监听页面载入事件以更新脚本执行计数;若为当前活动 tab,同步刷新 badge。 this.mq.subscribe( "pageLoad", async ({ tabId, frameId, scripts }: { tabId: number; frameId: number; document: string; scripts: Script[] }) => { await this.addScriptRunNumber({ tabId, frameId, scripts }); - // 设置角标 - await this.updateBadgeIcon(tabId); + // 设置角标 (chrome.tabs.onActivated 切换后) + if (lastActiveTabId > 0 && tabId === lastActiveTabId) { + await this.updateBadgeIcon(); + } } ); } diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 5e03ffa22..c1cf18c81 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -2,21 +2,40 @@ import type { Script, ScriptRunResource, SCRIPT_RUN_STATUS, SCMetadata, UserConf import { type URLRuleEntry } from "@App/pkg/utils/url_matcher"; import { type IGetSender } from "@Packages/message/server"; +/** 脚本安装来源 */ export type InstallSource = "user" | "system" | "sync" | "subscribe" | "vscode"; + +/** 搜索类型 */ export type SearchType = "auto" | "name" | "script_code"; +/** + * 脚本匹配信息。 + * 扩展自 ScriptRunResource。 + */ export interface ScriptMatchInfo extends ScriptRunResource { - scriptUrlPatterns: URLRuleEntry[]; // 已被自定义覆盖的 UrlPatterns - originalUrlPatterns: URLRuleEntry[]; // 脚本原本的 UrlPatterns + /** 已被自定义覆盖的 UrlPatterns */ + scriptUrlPatterns: URLRuleEntry[]; + /** 脚本原本的 UrlPatterns */ + originalUrlPatterns: URLRuleEntry[]; } +/** + * 脚本加载信息。 + * 包含脚本元数据与用户配置。 + */ export interface ScriptLoadInfo extends ScriptRunResource { - metadataStr: string; // 脚本元数据字符串 - userConfigStr: string; // 用户配置字符串 + /** 脚本元数据字符串 */ + metadataStr: string; + /** 用户配置字符串 */ + userConfigStr: string; + /** 用户配置对象(可选) */ userConfig?: UserConfig; } -// 为了优化性能,存储到缓存时删除了code、value与resource +/** + * 用于缓存的脚本匹配信息。 + * 为了优化性能,存储时删除了 code、value 与 resource。 + */ export type TScriptMatchInfoEntry = { code: ""; value: Record; @@ -24,6 +43,7 @@ export type TScriptMatchInfoEntry = { sort?: number; } & Omit; +/** 事件触发请求 */ export interface EmitEventRequest { uuid: string; event: string; @@ -31,8 +51,7 @@ export interface EmitEventRequest { data?: any; } -// GMApi,处理脚本的GM API调用请求 - +/** GMApi,处理脚本的 GM API 调用请求 */ export type MessageRequest = { uuid: string; // 脚本id api: string; @@ -47,42 +66,116 @@ export type Request = MessageRequest & { export type NotificationMessageOption = { event: "click" | "buttonClick" | "close"; params: { - /** - * event为buttonClick时存在该值 - * - * buttonClick的index - */ + /** 当 event 为 buttonClick 时存在,表示按钮索引 */ index?: number; - /** - * 是否是用户点击 - */ + /** 是否为用户点击触发 */ byUser?: boolean; }; }; export type Api = (request: Request, con: IGetSender) => Promise; -// popup +/** 脚本菜单选项 */ +export type ScriptMenuItemOption = { + id?: number; // 用于菜单修改及删除 (GM API) + accessKey?: string; // GM/TM 共通参数 + autoClose?: boolean; // SC独自设定。用于一般菜单项目。预设 true。false 时点击后不关闭菜单 + /** 可选输入框类型 */ + inputType?: "text" | "number" | "boolean"; + title?: string; // title 只适用于输入框类型 + inputLabel?: string; + inputDefaultValue?: string | number | boolean; + inputPlaceholder?: string; +}; + +/** + * 脚本菜单命令的「原始 ID」型别。 + * + * 来源: + * - 根据 Tampermonkey (TM) 定义,GM_registerMenuCommand 会回传一个「累计数字 ID」。 + * - 若透过 options.id 传入自订的 ID,则可能是 string 或 number。 + * + * 使用方式: + * - 若未指定,内部计数器自动生成数字 ID。 + * - 原始设计:数字 ID → `n{ID}`,字串 ID → `t{ID}`。 + * - 目前实现:统一转成 `t{ID}`。 + * + * 注意: + * - ID 仅为注册时的原始识别符,不保证跨 frame 唯一。 + * - 用于内部处理,不直接显示。 + */ +export type TScriptMenuItemID = number | string; +/** + * 用于 menu item 的显示名称。 + * 显示在右键菜单上的「文字名称」。 + * 例如:「开启设定」、「清除快取」。 + */ +export type TScriptMenuItemName = string; + +/** + * 菜单命令的「最终唯一键」型别。 + * + * 来源: + * - 由 TScriptMenuItemID 转换而来,并加上环境识别符 (contentEnvKey)。 + * - 规则:`{contentEnvKey}.t{ID}`,如 `main.t1`、`sub.t5`。 + * + * 特点: + * - 在整个执行环境中必须唯一。 + * - 即使命令名称相同,只要 key 不同,就能区分。 + */ +export type TScriptMenuItemKey = string; + +/** + * 单一的选单项目结构。 + * - groupKey:用来把「相同性质」的项目合并(例如 mainframe / subframe 都注册相同命令)。 + * - key:唯一键,对应 GM_registerMenuCommand 信息传递的第一个参数。 + * - name:显示文字。 + * - options:选单的额外设定,例如是否是输入框、是否自动关闭等。 + * - tabId:表示来自哪个分页,-1 表示背景脚本。 + * - frameId / documentId:用于区分 iframe 或特定文件。 + */ export type ScriptMenuItem = { - id: number; - name: string; - options?: { - id?: number; - autoClose?: boolean; - title?: string; - accessKey?: string; - // 可选输入框 - inputType?: "text" | "number" | "boolean"; - inputLabel?: string; - inputDefaultValue?: string | number | boolean; - inputPlaceholder?: string; - }; + groupKey: string; + key: TScriptMenuItemKey; + name: TScriptMenuItemName; + options?: ScriptMenuItemOption; tabId: number; //-1表示后台脚本 frameId?: number; documentId?: string; }; +/** + * 一组选单项目,对应到一个脚本 (uuid)。 + * - uuid:脚本唯一 ID。 + * - groupKey:分组键,确保 UI 显示时不重复。 + * - menus:此脚本在当前分页的所有选单项目。 + */ +export type GroupScriptMenuItem = { + uuid: string; + groupKey: string; + menus: ScriptMenuItem[]; +}; + +/** + * GM_registerMenuCommand 信息传递的呼叫参数型别: + * [唯一键, 显示名称, options(不包含 id 属性)] + * + * 使用范例: + * GM_registerMenuCommand信息传递("myKey", "开启设定", { autoClose: true }); + */ +export type GMRegisterMenuCommandParam = [TScriptMenuItemKey, TScriptMenuItemName, Omit]; + +/** + * GM_unregisterMenuCommand 信息传递的呼叫参数型别: + * [唯一键] + * + * 使用范例: + * GM_unregisterMenuCommand信息传递("myKey"); + */ +export type GMUnRegisterMenuCommandParam = [TScriptMenuItemKey]; + +/** 脚本菜单的完整信息 */ export type ScriptMenu = { uuid: string; // 脚本uuid name: string; // 脚本名称 @@ -95,9 +188,10 @@ export type ScriptMenu = { runNum: number; // 脚本运行次数 runNumByIframe: number; // iframe运行次数 menus: ScriptMenuItem[]; // 脚本菜单 - isEffective: boolean | null; // 是否在当前网址啟动 + isEffective: boolean | null; // 是否在当前网址启动 }; +/** 批量更新记录 */ export type TBatchUpdateRecord = | { uuid: string; @@ -125,21 +219,25 @@ export type TBatchUpdateRecord = withNewConnect: boolean; }; +/** 批量更新记录对象 */ export type TBatchUpdateRecordObject = { checktime?: number; list?: TBatchUpdateRecord[]; }; +/** 更新状态码 */ export const enum UpdateStatusCode { CHECKING_UPDATE = 1, CHECKED_BEFORE = 2, } +/** 批量更新动作码 */ export const enum BatchUpdateListActionCode { UPDATE = 1, IGNORE = 2, } +/** 批量更新动作 */ export type TBatchUpdateListAction = | { actionCode: BatchUpdateListActionCode.UPDATE; @@ -154,3 +252,5 @@ export type TBatchUpdateListAction = ignoreVersion: string; }[]; }; + +export type TPopupScript = { tabId: number; uuid: string }; diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx index a732c596c..e37b349a5 100644 --- a/src/pages/components/ScriptMenuList/index.tsx +++ b/src/pages/components/ScriptMenuList/index.tsx @@ -25,34 +25,47 @@ import { SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts"; import { RiPlayFill, RiStopFill } from "react-icons/ri"; import { useTranslation } from "react-i18next"; import { ScriptIcons } from "@App/pages/options/routes/utils"; -import type { ScriptMenu, ScriptMenuItem } from "@App/app/service/service_worker/types"; +import type { + GroupScriptMenuItem, + ScriptMenu, + ScriptMenuItem, + ScriptMenuItemOption, +} from "@App/app/service/service_worker/types"; import { popupClient, runtimeClient, scriptClient } from "@App/pages/store/features/script"; -import { messageQueue, systemConfig } from "@App/pages/store/global"; import { i18nName } from "@App/locales/locales"; -import { type TScriptRunStatus } from "@App/app/service/queue"; const CollapseItem = Collapse.Item; -const sendMenuAction = (uuid: string, menu: ScriptMenuItem, inputValue?: any) => { - popupClient.menuClick(uuid, menu, inputValue).then(() => { - menu.options?.autoClose !== false && window.close(); +const sendMenuAction = ( + uuid: string, + name: string, + options: ScriptMenuItemOption | undefined, + menus: ScriptMenuItem[], + inputValue?: any +) => { + Promise.allSettled(menus.map((menu) => popupClient.menuClick(uuid, menu, inputValue))).then(() => { + options?.autoClose !== false && window.close(); }); }; const FormItem = Form.Item; type MenuItemProps = { - menu: ScriptMenuItem; + menuItems: ScriptMenuItem[]; uuid: string; }; -const MenuItem = React.memo(({ menu, uuid }: MenuItemProps) => { - const initialValue = menu.options?.inputDefaultValue; +type GroupScriptMenuItemsProp = { group: GroupScriptMenuItem[]; menuUpdated: number }; + +const MenuItem = React.memo(({ menuItems, uuid }: MenuItemProps) => { + const menuItem = menuItems[0]; + const { name, options } = menuItem; + const initialValue = options?.inputDefaultValue; const InputMenu = (() => { - const placeholder = menu.options?.inputPlaceholder; + const placeholder = options?.inputPlaceholder; - switch (menu.options?.inputType) { + switch (options?.inputType) { case "text": return ; case "number": @@ -73,7 +86,7 @@ const MenuItem = React.memo(({ menu, uuid }: MenuItemProps) => { autoComplete="off" onSubmit={(v) => { const inputValue = v.inputValue; - sendMenuAction(uuid, menu, inputValue); + sendMenuAction(uuid, name, options, menuItems, inputValue); }} > {InputMenu && ( @@ -153,6 +166,7 @@ CollapseHeader.displayName = "CollapseHeader"; interface ListMenuItemProps { item: ScriptMenu; + scriptMenus: GroupScriptMenuItemsProp; menuExpandNum: number; isBackscript: boolean; url: URL | null; @@ -161,7 +175,7 @@ interface ListMenuItemProps { } const ListMenuItem = React.memo( - ({ item, menuExpandNum, isBackscript, url, onEnableChange, handleDeleteScript }: ListMenuItemProps) => { + ({ item, scriptMenus, menuExpandNum, isBackscript, url, onEnableChange, handleDeleteScript }: ListMenuItemProps) => { const { t } = useTranslation(); const [isEffective, setIsEffective] = useState(item.isEffective); @@ -172,14 +186,17 @@ const ListMenuItem = React.memo( }; const visibleMenus = useMemo(() => { - const m = item.menus; + const m = scriptMenus?.group || []; return m.length > menuExpandNum && !isExpand ? m.slice(0, menuExpandNum) : m; - }, [item.menus, isExpand, menuExpandNum]); + }, [scriptMenus?.group, isExpand, menuExpandNum]); - const shouldShowMore = useMemo(() => item.menus.length > menuExpandNum, [item.menus, menuExpandNum]); + const shouldShowMore = useMemo( + () => scriptMenus?.group?.length > menuExpandNum, + [scriptMenus?.group, menuExpandNum] + ); - const handleExcludeUrl = (item: ScriptMenu, excludePattern: string, isExclude: boolean) => { - scriptClient.excludeUrl(item.uuid, excludePattern, isExclude).finally(() => { + const handleExcludeUrl = (uuid: string, excludePattern: string, isExclude: boolean) => { + scriptClient.excludeUrl(uuid, excludePattern, isExclude).finally(() => { setIsEffective(isExclude); }); }; @@ -226,7 +243,7 @@ const ListMenuItem = React.memo( status="warning" type="secondary" icon={!isEffective ? : } - onClick={() => handleExcludeUrl(item, `*://${url.host}/*`, !isEffective)} + onClick={() => handleExcludeUrl(item.uuid, `*://${url.host}/*`, !isEffective)} > {(!isEffective ? t("exclude_on") : t("exclude_off")).replace("$0", `${url.host}`)} @@ -243,9 +260,10 @@ const ListMenuItem = React.memo(
- {/* 判断菜单数量,再判断是否展开 */} - {visibleMenus.map((menu) => { - return ; + {/* 依数量与展开状态决定要显示的分组项(收合时只显示前 menuExpandNum 笔) */} + {visibleMenus.map(({ uuid, groupKey, menus }) => { + // 不同脚本之间可能出现相同的 groupKey;为避免 React key 冲突,需加上 uuid 做区分。 + return ; })} {shouldShowMore && (