diff --git a/.gitignore b/.gitignore index b0bfc9b..380d827 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ __pycache__/ # C extensions *.so - +*.exe # Distribution / packaging .Python build/ @@ -165,4 +165,5 @@ toollib/frame.csv *.evf *.mvf old/ -发布流程.txt \ No newline at end of file +发布流程.txt +src/plugins/*/*.json diff --git a/requirements.txt b/requirements.txt index f436824..b3a8370 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ pyQt5==5.15.11 ms-toollib==1.5.1 -setuptools==78.1.1 +setuptools==80.9.0 pyinstaller==6.16.0 - +msgspec>=0.20.0 +zmq>=0.0.0 diff --git a/src/main.py b/src/main.py index 424b349..5ffb0d7 100644 --- a/src/main.py +++ b/src/main.py @@ -12,9 +12,50 @@ import ms_toollib as ms import ctypes from ctypes import wintypes +from mp_plugins.context import AppContext +from mp_plugins.events import * +from mp_plugins import PluginManager +from pathlib import Path +import os + os.environ["QT_FONT_DPI"] = "96" +# def patch_env(): +# import os + + +# env = os.environ.copy() +# root = os.path.dirname(os.path.abspath(__file__)) # 你的项目根目录 +# env["PYTHONPATH"] = root +# return env +def get_paths(): + if getattr(sys, "frozen", False): + # 打包成 exe + dir = os.path.dirname(sys.executable) # exe 所在目录 + else: + dir = os.path.dirname(os.path.abspath(__file__)) + + return dir + + +def patch_env(): + import os + import sys + + env = os.environ.copy() + + if getattr(sys, "frozen", False): + # 打包成 exe,库解压到 _MEIPASS + root = getattr(sys, "_MEIPASS", None) + else: + # 调试模式,库在项目目录 + root = os.path.dirname(os.path.abspath(__file__)) + + env["PYTHONPATH"] = root + return env + + def on_new_connection(localServer: QLocalServer): """当新连接进来时,接受连接并将文件路径传递给主窗口""" socket = localServer.nextPendingConnection() @@ -53,7 +94,7 @@ def find_window(class_name, window_name): """ - user32 = ctypes.WinDLL('user32', use_last_error=True) + user32 = ctypes.WinDLL("user32", use_last_error=True) user32.FindWindowW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR] user32.FindWindowW.restype = wintypes.HWND @@ -63,36 +104,39 @@ def find_window(class_name, window_name): return hwnd - def cli_check_file(file_path: str) -> int: if not os.path.exists(file_path): print("ERROR: file not found") return 2 - + # 搜集目录或文件下的所有evf和evfs文件 evf_evfs_files = [] - if os.path.isfile(file_path) and (file_path.endswith('.evf') or file_path.endswith('.evfs')): + if os.path.isfile(file_path) and ( + file_path.endswith(".evf") or file_path.endswith(".evfs") + ): evf_evfs_files = [os.path.abspath(file_path)] elif os.path.isdir(file_path): - evf_evfs_files = [os.path.abspath(os.path.join(root, file)) - for root, dirs, files in os.walk(file_path) - for file in files if file.endswith('.evf') or file.endswith('.evfs')] + evf_evfs_files = [ + os.path.abspath(os.path.join(root, file)) + for root, dirs, files in os.walk(file_path) + for file in files + if file.endswith(".evf") or file.endswith(".evfs") + ] if not evf_evfs_files: print("ERROR: must be evf or evfs files or directory") return 2 - - + # 实例化一个MineSweeperGUI出来 app = QtWidgets.QApplication(sys.argv) mainWindow = mainWindowGUI.MainWindow() ui = mineSweeperGUI.MineSweeperGUI(mainWindow, sys.argv) - + for ide, e in enumerate(evf_evfs_files): if not ui.checksum_module_ok(): print("ERROR: ???") return 2 - if e.endswith('.evf'): + if e.endswith(".evf"): # 检验evf文件是否合法 video = ms.EvfVideo(e) try: @@ -101,12 +145,13 @@ def cli_check_file(file_path: str) -> int: evf_evfs_files[ide] = (e, 2) else: checksum = ui.checksum_guard.get_checksum( - video.raw_data[:-(len(video.checksum) + 2)]) + video.raw_data[: -(len(video.checksum) + 2)] + ) if video.checksum == checksum: evf_evfs_files[ide] = (e, 0) else: evf_evfs_files[ide] = (e, 1) - elif e.endswith('.evfs'): + elif e.endswith(".evfs"): # 检验evfs文件是否合法 videos = ms.Evfs(e) try: @@ -116,21 +161,22 @@ def cli_check_file(file_path: str) -> int: else: if videos.len() <= 0: evf_evfs_files[ide] = (e, 2) - checksum = ui.checksum_guard.get_checksum( - videos[0].evf_video.raw_data) + checksum = ui.checksum_guard.get_checksum(videos[0].evf_video.raw_data) if video.checksum != checksum: evf_evfs_files[ide] = (e, 1) continue for idcell, cell in enumerate(videos[1:]): checksum = ui.checksum_guard.get_checksum( - cell.evf_video.raw_data + videos[idcell - 1].checksum) + cell.evf_video.raw_data + videos[idcell - 1].checksum + ) if cell.evf_file.checksum != checksum: evf_evfs_files[ide] = (e, 1) continue evf_evfs_files[ide] = (e, 0) print(evf_evfs_files) return 0 - + + if __name__ == "__main__": # metaminesweeper.exe -c filename.evf用法,检查文件的合法性 # metaminesweeper.exe -c filename.evfs @@ -142,7 +188,11 @@ def cli_check_file(file_path: str) -> int: if args.check: exit_code = cli_check_file(args.check) sys.exit(exit_code) - + env = patch_env() + context = AppContext("Metasweeper", "1.0.0", "元扫雷") + PluginManager.instance().context = context + + PluginManager.instance().start(Path(get_paths()) / "plugins", env) app = QtWidgets.QApplication(sys.argv) serverName = "MineSweeperServer" @@ -159,7 +209,8 @@ def cli_check_file(file_path: str) -> int: localServer = QLocalServer() localServer.listen(serverName) localServer.newConnection.connect( - lambda: on_new_connection(localServer=localServer)) + lambda: on_new_connection(localServer=localServer) + ) mainWindow = mainWindowGUI.MainWindow() ui = mineSweeperGUI.MineSweeperGUI(mainWindow, sys.argv) ui.mainWindow.show() @@ -169,16 +220,21 @@ def cli_check_file(file_path: str) -> int: hwnd = find_window(None, _translate("MainWindow", "元扫雷")) SetWindowDisplayAffinity = ctypes.windll.user32.SetWindowDisplayAffinity - ui.disable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000011) else 1/0 - ui.enable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000000) else 1/0 - ui.disable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000011) else 1/0 - ui.enable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000000) else 1/0 + ui.disable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000011) else 1 / 0 + ) + ui.enable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000000) else 1 / 0 + ) + ui.disable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000011) else 1 / 0 + ) + ui.enable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000000) else 1 / 0 + ) sys.exit(app.exec_()) + PluginManager.instance().stop() ... # except: # pass diff --git a/src/mineSweeperGUI.py b/src/mineSweeperGUI.py index 816eaf2..4357a91 100644 --- a/src/mineSweeperGUI.py +++ b/src/mineSweeperGUI.py @@ -26,7 +26,9 @@ from mainWindowGUI import MainWindow from datetime import datetime from mineSweeperVideoPlayer import MineSweeperVideoPlayer - +from pluginDialog import PluginManagerUI +from mp_plugins import PluginManager, PluginContext +from mp_plugins.events import GameEndEvent class MineSweeperGUI(MineSweeperVideoPlayer): def __init__(self, MainWindow: MainWindow, args): @@ -37,7 +39,6 @@ def __init__(self, MainWindow: MainWindow, args): self.time_10ms: int = 0 # 已毫秒为单位的游戏时间,全局统一的 self.showTime(self.time_10ms // 100) - self.timer_10ms = QTimer() self.timer_10ms.setInterval(10) # 10毫秒回调一次的定时器 self.timer_10ms.timeout.connect(self.timeCount) @@ -69,6 +70,7 @@ def save_evf_file_integrated(): self.actiongaun_yv.triggered.connect(self.action_AEvent) self.actionauto_update.triggered.connect(self.auto_Update) self.actionopen.triggered.connect(self.action_OpenFile) + self.actionchajian.triggered.connect(self.action_OpenPluginDialog) self.english_action.triggered.connect( lambda: self.trans_language("en_US")) self.chinese_action.triggered.connect( @@ -134,7 +136,7 @@ def save_evf_file_integrated(): self.mainWindow.closeEvent_.connect(self.closeEvent_) self.mainWindow.dropFileSignal.connect(self.action_OpenFile) - + # 播放录像时,记录上一个鼠标状态用。 # 这是一个补丁,因为工具箱里只有UpDown和UpDownNotFlag, # 也有DownUpAfterChording,但是没有UpDownAfterChording @@ -247,8 +249,7 @@ def game_state(self, game_state: str): case "study": self.num_bar_ui.QWidget.close() self._game_state = game_state - - + @property def row(self): return self._row @@ -259,7 +260,7 @@ def row(self, row): "row": row, }) self._row = row - + @property def column(self): return self._column @@ -270,7 +271,7 @@ def column(self, column): "column": column, }) self._column = column - + @property def minenum(self): return self._minenum @@ -281,7 +282,6 @@ def minenum(self, minenum): "minenum": minenum, }) self._minenum = minenum - def layMine(self, i, j): xx = self.row @@ -452,8 +452,6 @@ def chording_ai(self, i, j): not_mine_round + is_mine_round) self.label.ms_board.board = board - - def mineNumWheel(self, i): ''' 在雷上滚轮,调雷数 @@ -564,6 +562,8 @@ def gameFinished(self): self.score_board_manager.show(self.label.ms_board, index_type=2) self.enable_screenshot() self.unlimit_cursor() + event = GameEndEvent() + PluginManager.instance().send_event(event, response_count=0) def gameWin(self): # 成功后改脸和状态变量,停时间 self.timer_10ms.stop() @@ -580,7 +580,7 @@ def gameWin(self): # 成功后改脸和状态变量,停时间 if self.autosave_video and self.checksum_module_ok(): self.dump_evf_file_data() self.save_evf_file() - + self.gameFinished() # 尝试弹窗,没有破纪录则不弹 @@ -601,10 +601,10 @@ def dump_evf_file_data(self): self.label.ms_board.use_question = False # 禁用问号是共识 self.label.ms_board.use_cursor_pos_lim = self.cursor_limit self.label.ms_board.use_auto_replay = self.auto_replay > 0 - + self.label.ms_board.is_fair = self.is_fair() self.label.ms_board.is_official = self.is_official() - + self.label.ms_board.software = superGUI.version self.label.ms_board.mode = self.gameMode self.label.ms_board.player_identifier = self.player_identifier @@ -614,7 +614,7 @@ def dump_evf_file_data(self): country_name[self.country].upper() self.label.ms_board.device_uuid = hashlib.md5( bytes(str(uuid.getnode()).encode())).hexdigest().encode("UTF-8") - + self.label.ms_board.generate_evf_v4_raw_data() # 补上校验值 checksum = self.checksum_guard.get_checksum( @@ -641,8 +641,7 @@ def save_evf_file(self): os.mkdir(self.replay_path) self.label.ms_board.save_to_evf_file(self.cal_evf_filename()) - - + # 拼接evf录像的文件名,无后缀 def cal_evf_filename(self, absolute=True) -> str: if (self.label.ms_board.row, self.label.ms_board.column, self.label.ms_board.mine_num) == (8, 8, 10): @@ -676,8 +675,6 @@ def cal_evf_filename(self, absolute=True) -> str: file_name += "_fake" return file_name - - # 保存evfs文件。先保存后一个文件,再删除前一个文件。 def save_evfs_file(self): # 文件名包含秒为单位的时间戳,理论上不会重复 @@ -698,7 +695,6 @@ def save_evfs_file(self): self.evfs.save_evfs_file(file_name + "1") self.old_evfs_filename = file_name - def gameFailed(self): # 失败后改脸和状态变量 self.timer_10ms.stop() self.score_board_manager.editing_row = -1 @@ -876,8 +872,7 @@ def try_record_pop(self): ui.label_16.setText(mode_text) ui.Dialog.show() ui.Dialog.exec_() - - + # 根据条件是否满足,尝试追加evfs文件 # 当且仅当game_state发生变化,且旧状态为"playing"时调用(即使点一下就获胜也会经过"playing") # 加入evfs是空的,且当前游戏状态不是"win",则不追加 @@ -901,10 +896,10 @@ def try_append_evfs(self, new_game_state): self.label.ms_board.use_question = False # 禁用问号是共识 self.label.ms_board.use_cursor_pos_lim = self.cursor_limit self.label.ms_board.use_auto_replay = self.auto_replay > 0 - + self.label.ms_board.is_fair = self.is_fair() self.label.ms_board.is_official = self.is_official() - + self.label.ms_board.software = superGUI.version self.label.ms_board.mode = self.gameMode self.label.ms_board.player_identifier = self.player_identifier @@ -914,7 +909,7 @@ def try_append_evfs(self, new_game_state): country_name[self.country].upper() self.label.ms_board.device_uuid = hashlib.md5( bytes(str(uuid.getnode()).encode())).hexdigest().encode("UTF-8") - + self.label.ms_board.generate_evf_v4_raw_data() # 补上校验值 checksum = self.checksum_guard.get_checksum( @@ -937,7 +932,6 @@ def try_append_evfs(self, new_game_state): self.cal_evf_filename(absolute=False), checksum) self.evfs.generate_evfs_v0_raw_data() self.save_evfs_file() - def showMineNum(self, n): # 显示剩余雷数,雷数大于等于0,小于等于999,整数 @@ -1005,7 +999,6 @@ def predefined_Board(self, k): self.board_constraint = self.predefinedBoardPara[k]['board_constraint'] self.attempt_times_limit = self.predefinedBoardPara[k]['attempt_times_limit'] - # 菜单回放的回调 def replay_game(self): if not isinstance(self.label.ms_board, ms.BaseVideo): @@ -1374,8 +1367,6 @@ def refreshSettingsDefault(self): self.game_setting.set_value("DEFAULT/minenum", str(self.minenum)) self.game_setting.sync() - - def is_official(self) -> bool: # 局面开始时,判断一下局面是设置是否正式。 # 极端小的3BV依然是合法的,而网站是否认同不关软件的事。 @@ -1423,7 +1414,6 @@ def limit_cursor(self): rect = QRect(widget_pos, widget_size) self._clip_mouse(rect) - def unlimit_cursor(self): ''' 取消将鼠标区域限制在游戏界面中。 @@ -1473,3 +1463,8 @@ def closeEvent_(self): self.game_setting.sync() self.record_setting.sync() + + def action_OpenPluginDialog(self): + contexts = list(PluginManager.instance().plugin_contexts) + dialog = PluginManagerUI(contexts) + dialog.exec() diff --git a/src/mp_plugins/__init__.py b/src/mp_plugins/__init__.py new file mode 100644 index 0000000..80e2a28 --- /dev/null +++ b/src/mp_plugins/__init__.py @@ -0,0 +1,13 @@ +from .plugin_process import PluginProcess +from .plugin_manager import PluginManager +from .base import BaseContext, BaseEvent, BasePlugin, BaseConfig +from .base.context import PluginContext + +__all__ = [ + "PluginManager", + "BaseContext", + "BaseEvent", + "BasePlugin", + "BaseConfig", + "PluginContext", +] diff --git a/src/mp_plugins/base/__init__.py b/src/mp_plugins/base/__init__.py new file mode 100644 index 0000000..50f7b84 --- /dev/null +++ b/src/mp_plugins/base/__init__.py @@ -0,0 +1,33 @@ +from .context import BaseContext, PluginContext +from .error import Error +from .mode import PluginStatus, MessageMode, ValueEnum +from .event import BaseEvent +from .plugin import BasePlugin +from .message import Message +from ._data import get_subclass_by_name +from .config import ( + BaseConfig, + BaseSetting, + BoolSetting, + NumberSetting, + SelectSetting, + TextSetting, +) + +__all__ = [ + "BaseContext", + "PluginContext", + "Error", + "PluginStatus", + "MessageMode", + "BaseEvent", + "BasePlugin", + "Message", + "ValueEnum", + "BaseConfig", + "BaseSetting", + "BoolSetting", + "NumberSetting", + "SelectSetting", + "TextSetting", +] diff --git a/src/mp_plugins/base/_data.py b/src/mp_plugins/base/_data.py new file mode 100644 index 0000000..a3386c8 --- /dev/null +++ b/src/mp_plugins/base/_data.py @@ -0,0 +1,36 @@ +from typing import Type +from msgspec import Struct, json + + +class _BaseData(Struct): + """ + 数据基类 + """ + + def copy(self): + new_data = json.decode(json.encode(self), type=type(self)) + return new_data + + +_subclass_cache = {} + + +def get_subclass_by_name(name: str) -> Type[_BaseData] | None: + """ + 根据类名获取 BaseEvent 的派生类(支持多级继承),带缓存 + """ + global _subclass_cache + if name in _subclass_cache: + return _subclass_cache[name] + + def _iter_subclasses(cls): + for sub in cls.__subclasses__(): + yield sub + yield from _iter_subclasses(sub) + + for subcls in _iter_subclasses(_BaseData): + _subclass_cache[subcls.__name__] = subcls + if subcls.__name__ == name: + return subcls + + return None diff --git a/src/mp_plugins/base/config.py b/src/mp_plugins/base/config.py new file mode 100644 index 0000000..9fbd271 --- /dev/null +++ b/src/mp_plugins/base/config.py @@ -0,0 +1,52 @@ +from typing import Any, Dict, List, Sequence +from ._data import _BaseData, get_subclass_by_name +import msgspec + + +class BaseSetting(_BaseData): + name: str = "" + value: Any = None + setting_type: str = "BaseSetting" + + +class TextSetting(BaseSetting): + value: str = "" + placeholder: str = "" + setting_type: str = "TextSetting" + + +class NumberSetting(BaseSetting): + value: float = 0.0 + min_value: float = 0.0 + max_value: float = 100.0 + step: float = 1.0 + setting_type: str = "NumberSetting" + + +class BoolSetting(BaseSetting): + value: bool = False + description: str = "" + setting_type: str = "BoolSetting" + + +class SelectSetting(BaseSetting): + value: str = "" + options: List[str] = [] + setting_type: str = "SelectSetting" + + +class BaseConfig(_BaseData): + """ """ + + pass + + +def Get_settings(data: Dict[str, Dict[str, Any]]) -> Dict[str, BaseSetting]: + settings = {} + for key, value in data.items(): + if settings_type := value.get("setting_type"): + setting: BaseSetting = msgspec.json.decode( + msgspec.json.encode(value), type=get_subclass_by_name(settings_type) + ) + settings[key] = setting + return settings diff --git a/src/mp_plugins/base/context.py b/src/mp_plugins/base/context.py new file mode 100644 index 0000000..178305c --- /dev/null +++ b/src/mp_plugins/base/context.py @@ -0,0 +1,31 @@ +from ._data import _BaseData +from .mode import PluginStatus +from datetime import datetime +from typing import List + + +class BaseContext(_BaseData): + """ + 上下文基类 + """ + + name: str = "" + version: str = "" + + +class PluginContext(BaseContext): + """ + 插件上下文 + """ + + pid: int = 0 + name: str = "" + display_name: str = "" + description: str = "" + version: str = "" + author: str = "" + author_email: str = "" + url: str = "" + status: PluginStatus = PluginStatus.Stopped + heartbeat: float = datetime.now().timestamp() + subscribers: List[str] = [] diff --git a/src/mp_plugins/base/error.py b/src/mp_plugins/base/error.py new file mode 100644 index 0000000..1e94acb --- /dev/null +++ b/src/mp_plugins/base/error.py @@ -0,0 +1,10 @@ +from ._data import _BaseData + + +class Error(_BaseData): + """ + 错误信息 + """ + + type: str + message: str diff --git a/src/mp_plugins/base/event.py b/src/mp_plugins/base/event.py new file mode 100644 index 0000000..55565df --- /dev/null +++ b/src/mp_plugins/base/event.py @@ -0,0 +1,11 @@ +from ._data import _BaseData +from datetime import datetime +import uuid + + +class BaseEvent(_BaseData): + """ + 事件基类 + """ + + timestamp: float = datetime.now().timestamp() diff --git a/src/mp_plugins/base/message.py b/src/mp_plugins/base/message.py new file mode 100644 index 0000000..43daa5c --- /dev/null +++ b/src/mp_plugins/base/message.py @@ -0,0 +1,32 @@ +import uuid +from msgspec import Struct, json +from typing import Any, Union, Type, TypeVar, Generic, Optional +from ._data import _BaseData, get_subclass_by_name +from datetime import datetime +from .mode import MessageMode + +BaseData = TypeVar("BaseData", bound=_BaseData) + + +class Message(_BaseData): + """ + 一个消息类用于包装事件及消息的基本信息 + """ + + id = str(uuid.uuid4()) + data: Any = None + timestamp: datetime = datetime.now() + mode: MessageMode = MessageMode.Unknown + Source: str = "main" # 来源,也就是消息的发送者 + class_name: str = "" + + def copy(self): + new_message = json.decode(json.encode(self), type=Message) + return new_message + + def __post_init__(self): + cls = get_subclass_by_name(self.class_name) + if cls: + # 将原始 dict 解析成对应的 Struct + if isinstance(self.data, dict): + self.data = cls(**self.data) diff --git a/src/mp_plugins/base/mode.py b/src/mp_plugins/base/mode.py new file mode 100644 index 0000000..0e92a59 --- /dev/null +++ b/src/mp_plugins/base/mode.py @@ -0,0 +1,26 @@ +from enum import Enum + + +class ValueEnum(Enum): + def __eq__(self, value: object) -> bool: + if isinstance(value, Enum): + return self.value == value.value + return self.value == value # 支持直接比较 value + + +class PluginStatus(ValueEnum): + """ + 插件状态 + """ + + Running = "running" + Stopped = "stopped" + Dead = "dead" + + +class MessageMode(ValueEnum): + Event = "event" + Context = "context" + Error = "error" + Unknown = "unknown" + Heartbeat = "heartbeat" diff --git a/src/mp_plugins/base/plugin.py b/src/mp_plugins/base/plugin.py new file mode 100644 index 0000000..a7f6edc --- /dev/null +++ b/src/mp_plugins/base/plugin.py @@ -0,0 +1,188 @@ +from abc import ABC, abstractmethod +import inspect +from msgspec import json +from datetime import datetime, timedelta +from .error import Error +import os +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Type, + Union, + TypeVar, + Generic, + ParamSpec, +) +from .message import Message, BaseData, MessageMode +from ._data import _BaseData, get_subclass_by_name +from .event import BaseEvent +from .context import BaseContext, PluginContext +from queue import Queue +import zmq +from .mode import PluginStatus +from .config import BaseConfig, BaseSetting + +P = ParamSpec("P") # 捕获参数 +R = TypeVar("R") # 捕获返回值 + + +class BasePlugin(ABC): + """ + 插件基类 + """ + + _context: BaseContext + _plugin_context: PluginContext = PluginContext() + _config: BaseConfig + + @abstractmethod + def build_plugin_context(self) -> None: ... + + @staticmethod + def event_handler( + event: Type[BaseEvent], + ) -> Callable[[Callable[P, BaseEvent]], Callable[P, BaseEvent]]: + """ + 装饰器:标记方法为事件 handler + """ + + def decorator(func: Callable[P, BaseEvent]) -> Callable[P, BaseEvent]: + func.__event_handler__ = event + return func + + return decorator + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + cls._event_handlers: Dict[ + Type[BaseEvent], List[Callable[[BasePlugin, BaseEvent], BaseEvent]] + ] = {} + event_set: set[str] = set() + for _, value in cls.__dict__.items(): + if hasattr(value, "__event_handler__"): + if value.__event_handler__ not in cls._event_handlers: + cls._event_handlers[value.__event_handler__] = [] + event_set.add(value.__event_handler__.__name__) + cls._event_handlers[value.__event_handler__].append(value) + cls._plugin_context.subscribers = list(event_set) + cls._plugin_context.pid = os.getpid() + + def __init__(self) -> None: + super().__init__() + context = zmq.Context() + self.dealer = context.socket(zmq.DEALER) + self.__message_queue: Queue[Message] = Queue() + self.__heartbeat_time = datetime.now() + self.build_plugin_context() + + @abstractmethod + def initialize(self) -> None: + self._plugin_context.status = PluginStatus.Running + self.refresh_context() + + @abstractmethod + def shutdown(self) -> None: + self._plugin_context.status = PluginStatus.Stopped + self.refresh_context() + + def run(self, host: str, port: int) -> None: + self.initialize() + self.dealer.setsockopt_string(zmq.IDENTITY, str(self._plugin_context.pid)) + self.dealer.connect(f"tcp://{host}:{port}") + self.refresh_context() + while datetime.now() - self.__heartbeat_time < timedelta(seconds=30): + while not self.__message_queue.empty(): + message = self.__message_queue.get() + self.dealer.send_multipart([b"", json.encode(message)]) + try: + data = self.dealer.recv(flags=zmq.NOBLOCK) + except zmq.Again: + continue + if data: + message = json.decode(data, type=Message) + self.__message_dispatching(message) + self.shutdown() + self.dealer.close() + + def __message_dispatching(self, message: Message) -> None: + """ + 消息分发 + """ + new_message = message.copy() + new_message.Source = self.__class__.__name__ + self.__heartbeat_time = datetime.now() + if message.mode == MessageMode.Event: + if isinstance(message.data, BaseEvent) and message.data is not None: + if message.data.__class__ not in self._event_handlers: + self.send_error( + type="Event Subscribe", + error=f"{self.__class__.__name__} not Subscribe {message.data.__class__.__name__}", + ) + return + for handler in self._event_handlers[message.data.__class__]: + event = handler(self, message.data) + new_message.data = event + self.__message_queue.put(new_message) + else: + self.send_error( + type="Event Validation", + error=f"{message.data} is not a valid event", + ) + elif message.mode == MessageMode.Context: + if isinstance(message.data, BaseContext) and message.data is not None: + self._context = message.data + elif message.mode == MessageMode.Error: + pass + elif message.mode == MessageMode.Unknown: + pass + elif message.mode == MessageMode.Heartbeat: + self.__message_queue.put(new_message) + + def refresh_context(self): + self._plugin_context.heartbeat = datetime.now().timestamp() + self.__message_queue.put( + Message( + data=self._plugin_context, + mode=MessageMode.Context, + Source=self.__class__.__name__, + class_name=self._plugin_context.__class__.__name__, + ) + ) + + @property + def context(self): + return self._context + + def send_error(self, type: str, error: str): + self.__message_queue.put( + Message( + data=Error(type=type, message=error), + mode=MessageMode.Error, + Source=self.__class__.__name__, + ) + ) + + def init_config(self, config: BaseConfig): + self._config = config + old_config = self.config + dir = os.path.dirname(inspect.getfile(self.__class__)) + config_path = os.path.join(dir, f"{self.__class__.__name__}.json") + if old_config is None: + with open(config_path, "w", encoding="utf-8") as f: + b = json.encode(config) + f.write(b.decode("utf-8")) + + @property + def config(self): + dir = os.path.dirname(inspect.getfile(self.__class__)) + config_path = os.path.join(dir, f"{self.__class__.__name__}.json") + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.decode(f.read(), type=self._config.__class__) + return config + except: + return None diff --git a/src/mp_plugins/context.py b/src/mp_plugins/context.py new file mode 100644 index 0000000..88ffd8b --- /dev/null +++ b/src/mp_plugins/context.py @@ -0,0 +1,5 @@ +from .base import BaseContext + + +class AppContext(BaseContext): + display_name: str = "元扫雷" diff --git a/src/mp_plugins/events.py b/src/mp_plugins/events.py new file mode 100644 index 0000000..0a23bcf --- /dev/null +++ b/src/mp_plugins/events.py @@ -0,0 +1,5 @@ +from .base import BaseEvent + + +class GameEndEvent(BaseEvent): + pass diff --git a/src/mp_plugins/plugin_manager.py b/src/mp_plugins/plugin_manager.py new file mode 100644 index 0000000..f4595eb --- /dev/null +++ b/src/mp_plugins/plugin_manager.py @@ -0,0 +1,255 @@ +from datetime import datetime, timedelta +import sys +import threading +from typing import Callable, Dict, List, Optional, TypeVar, Generic, Sequence +from threading import RLock +from .base import ( + PluginContext, + BaseContext, + BaseEvent, + MessageMode, + Message, + Error, + PluginStatus, +) +import zmq +from .base._data import _BaseData +from queue import Queue +import time +import msgspec +from .plugin_process import PluginProcess +from .base.config import BaseConfig, BaseSetting, Get_settings +import pathlib +import json + +_Event = TypeVar("_Event", bound=BaseEvent) + + +class PluginManager(object): + + __instance: Optional["PluginManager"] = None + __lock = RLock() + __plugins_context: Dict[str, PluginContext] = {} + __plugins_process: Dict[str, PluginProcess] = {} + __plugins_config_path: Dict[str, pathlib.Path] = {} + __context: BaseContext + __event_dict: Dict[str, List[BaseEvent]] = {} + __message_queue: Queue[Message] = Queue() + __running: bool = False + + def __init__(self) -> None: + context = zmq.Context() + + self.__router = context.socket(zmq.ROUTER) + self.__port = self.__router.bind_to_random_port("tcp://127.0.0.1") + self.__error_callback = None + + @property + def port(self): + return self.__port + + @classmethod + def instance(cls) -> "PluginManager": + if cls.__instance is None: + with cls.__lock: + if cls.__instance is None: + cls.__instance = cls() + return cls.__instance + + def start(self, plugin_dir: pathlib.Path, env: Dict[str, str]): + with self.__lock: + if self.__running: + return + self.__running = True + plugin_dir.mkdir(parents=True, exist_ok=True) + for plugin_path in plugin_dir.iterdir(): + if plugin_path.is_dir(): + if plugin_path.name.startswith("__"): + continue + self.__plugins_config_path[plugin_path.name] = plugin_path / ( + plugin_path.name + ".json" + ) + # 先判断是否有exe + if (plugin_path / (plugin_path.name + ".exe")).exists(): + plugin_path = plugin_path / (plugin_path.name + ".exe") + else: + plugin_path = plugin_path / (plugin_path.name + ".py") + process = PluginProcess(plugin_path) + process.start("127.0.0.1", self.__port, env=env) + self.__plugins_process[str(process.pid)] = process + + t = threading.Thread(target=self.__message_dispatching, daemon=True) + self.__request_thread = t + t2 = threading.Thread(target=self.__async_send_message, daemon=True) + self.__response_thread = t2 + t3 = threading.Thread(target=self.__send_heartbeat, daemon=True) + self.__heartbeat_thread = t3 + t3.start() + t.start() + t2.start() + print("Plugin manager started") + + def stop(self): + with self.__lock: + if not self.__running: + return + self.__running = False + for process in self.__plugins_process.values(): + process.stop() + self.__request_thread.join(10) + self.__response_thread.join(10) + self.__heartbeat_thread.join(10) + self.__router.close() + print("Plugin manager stopped") + + @property + def plugin_contexts(self): + for context in self.__plugins_context.values(): + yield context.copy() + + @property + def context(self) -> BaseContext: + return self.__context + + @context.setter + def context(self, context: BaseContext): + self.__context = context + + def send_event( + self, event: _Event, timeout: int = 10, response_count: int = 1 + ) -> Sequence[_Event]: + message = Message( + data=event, mode=MessageMode.Event, class_name=event.__class__.__name__ + ) + self.__event_dict[message.id] = [] + self.__send_message(message) + current_time = datetime.now() + while datetime.now() - current_time < timedelta(seconds=timeout) and ( + len(self.__event_dict.get(message.id, [])) < response_count + ): + time.sleep(0.1) + result = [] + if message.id in self.__event_dict: + for response in self.__event_dict[message.id]: + result.append(response) + with self.__lock: + del self.__event_dict[message.id] + return result + + def __send_heartbeat(self): + while self.__running: + time.sleep(5) + for context in self.__plugins_context.values(): + if datetime.now().timestamp() - context.heartbeat > 3000: + context.status = PluginStatus.Dead + if context.status == PluginStatus.Running: + self.__send_message(Message(mode=MessageMode.Heartbeat)) + + def __send_message(self, message: Message): + self.__message_queue.put(message) + + def bind_error(self, func: Callable[[Error], None]): + self.__error_callback = func + + def __message_dispatching(self) -> None: + while self.__running: + time.sleep(0.1) + try: + identity, _, data = self.__router.recv_multipart(flags=zmq.NOBLOCK) + except zmq.Again: + continue + if not data: + continue + if identity.decode() in self.__plugins_context: + self.__plugins_context[identity.decode()].heartbeat = ( + datetime.now().timestamp() + ) + message = msgspec.json.decode(data, type=Message) + if message.mode == MessageMode.Event: + with self.__lock: + if message.id in self.__event_dict and isinstance( + message.data, BaseEvent + ): + self.__event_dict[message.id].append(message.data) + elif message.mode == MessageMode.Error: + if self.__error_callback is not None and isinstance( + message.data, Error + ): + self.__error_callback(message.data) + elif message.mode == MessageMode.Heartbeat: + pass + elif message.mode == MessageMode.Context: + if isinstance(message.data, PluginContext): + with self.__lock: + self.__plugins_context[identity.decode()] = message.data + self.__send_message( + Message( + data=self.context, + mode=MessageMode.Context, + class_name=self.context.__class__.__name__, + ) + ) + + def __async_send_message(self): + while self.__running: + time.sleep(0.1) + if self.__message_queue.empty(): + continue + message = self.__message_queue.get() + if message.mode == MessageMode.Event: + for context in self.__plugins_context.values(): + event_name = message.data.__class__.__name__ + if ( + event_name in context.subscribers + and context.status == PluginStatus.Running + ): + self.__router.send_multipart( + [ + str(context.pid).encode(), + b"", + msgspec.json.encode(message), + ] + ) + elif message.mode == MessageMode.Heartbeat: + for context in self.__plugins_context.values(): + if context.status == PluginStatus.Running: + self.__router.send_multipart( + [ + str(context.pid).encode(), + b"", + msgspec.json.encode(message), + ] + ) + elif message.mode == MessageMode.Context: + for context in self.__plugins_context.values(): + if context.status == PluginStatus.Running: + self.__router.send_multipart( + [ + str(context.pid).encode(), + b"", + msgspec.json.encode(message), + ] + ) + + def Get_Settings(self, plugin_name: str): + if plugin_name not in self.__plugins_config_path: + return {} + if not self.__plugins_config_path[plugin_name].exists(): + return {} + + data = json.loads(self.__plugins_config_path[plugin_name].read_text("utf-8")) + + return Get_settings(data) + + def Set_Settings(self, plugin_name: str, name: str, setting: BaseSetting): + data = self.Get_Settings(plugin_name) + + new_data = {} + data[name] = setting + for key, value in data.items(): + if isinstance(value, BaseSetting): + new_data[key] = msgspec.structs.asdict(value) + + self.__plugins_config_path[plugin_name].write_text( + json.dumps(new_data, indent=4) + ) diff --git a/src/mp_plugins/plugin_process.py b/src/mp_plugins/plugin_process.py new file mode 100644 index 0000000..287798f --- /dev/null +++ b/src/mp_plugins/plugin_process.py @@ -0,0 +1,58 @@ +import sys +import subprocess +import os +from pathlib import Path +from typing import List, Optional, Dict, Any + + +class PluginProcess(object): + + def __init__(self, plugin_path: Path) -> None: + self.__plugin_path = plugin_path + self._process: Optional[subprocess.Popen] = None + self.__pid: int + self.plugin_dir_name = plugin_path.parent.parent.name + self.plugin_name = plugin_path.name.split(".")[0] + + @property + def pid(self): + return self.__pid + + @property + def plugin_path(self): + return self.__plugin_path + + def start(self, host: str, port: int, env: Dict[str, str]): + if self._process is not None: + return + module = f"{self.plugin_dir_name}.{self.plugin_name}.{self.plugin_name}" + if self.__plugin_path.suffix == ".py": + self._process = subprocess.Popen( + [ + sys.executable, + "-m", + module, + host, + str(port), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + else: + self._process = subprocess.Popen( + [ + self.__plugin_path, + host, + str(port), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.__pid = self._process.pid + + def stop(self): + if self._process is None: + return + if self._process.poll() is None: + self._process.terminate() diff --git a/src/pluginDialog.py b/src/pluginDialog.py new file mode 100644 index 0000000..e2250aa --- /dev/null +++ b/src/pluginDialog.py @@ -0,0 +1,204 @@ +from PyQt5.QtWidgets import ( + QApplication, + QDialog, + QListWidget, + QFormLayout, + QLabel, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QGroupBox, + QMessageBox, + QScrollArea, + QWidget, + QLineEdit, + QDoubleSpinBox, + QCheckBox, + QComboBox, +) +from PyQt5.QtCore import Qt +import sys +from typing import Dict +from mp_plugins import PluginContext + +# 你的 Setting 类 +from mp_plugins.base import ( + BaseSetting, + TextSetting, + NumberSetting, + BoolSetting, + SelectSetting, +) +from mp_plugins import PluginManager + + +class PluginManagerUI(QDialog): + + def __init__(self, plugin_contexts: list[PluginContext]): + """ + plugin_contexts: 你传入的 plugin 列表 + get_settings_func(plugin_ctx) -> Dict[str, BaseSetting] + 你自己的函数,用来根据 PluginContext 拿到 settings + """ + super().__init__() + + self.plugin_contexts = plugin_contexts + self.current_settings_widgets: Dict[str, QWidget] = {} + + self.setWindowTitle("插件管理") + self.resize(1000, 650) + + root_layout = QHBoxLayout(self) + + # ================= 左侧插件列表 ================= + self.list_widget = QListWidget() + for ctx in self.plugin_contexts: + self.list_widget.addItem(ctx.display_name or ctx.name) + + self.list_widget.currentRowChanged.connect(self.on_plugin_selected) + root_layout.addWidget(self.list_widget, 1) + + # ================= 右侧 ================= + right_layout = QVBoxLayout() + + # -------- 插件详情 -------- + details_group = QGroupBox("插件详情") + self.details_layout = QFormLayout() + + self.detail_labels = { + "pid": QLabel(), + "name": QLabel(), + "display_name": QLabel(), + "description": QLabel(), + "version": QLabel(), + "author": QLabel(), + "author_email": QLabel(), + "url": QLabel(), + "status": QLabel(), + "heartbeat": QLabel(), + "subscribers": QLabel(), + } + + for key, widget in self.detail_labels.items(): + self.details_layout.addRow(key.replace("_", " ").title() + ":", widget) + + details_group.setLayout(self.details_layout) + right_layout.addWidget(details_group, 1) + + # -------- 设置面板(滚动) -------- + settings_group = QGroupBox("设置") + vbox = QVBoxLayout() + + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + + self.scroll_content = QWidget() + self.scroll_layout = QFormLayout(self.scroll_content) + self.scroll_area.setWidget(self.scroll_content) + + vbox.addWidget(self.scroll_area, 1) + + # 保存按钮 + self.btn_save = QPushButton("保存") + self.btn_save.clicked.connect(self.on_save_clicked) + vbox.addWidget(self.btn_save) + + settings_group.setLayout(vbox) + right_layout.addWidget(settings_group, 2) + + root_layout.addLayout(right_layout, 2) + + if plugin_contexts: + self.list_widget.setCurrentRow(0) + + # =================================================================== + # 左侧切换插件 + # =================================================================== + def on_plugin_selected(self, index: int): + if index < 0: + return + + ctx = self.plugin_contexts[index] + + # --- PluginContext 原样填充 --- + for key, label in self.detail_labels.items(): + value = getattr(ctx, key) + if isinstance(value, list): + value = ", ".join(value) + label.setText(str(value)) + + # --- 你自己的获取 settings 的函数 --- + settings_dict = PluginManager.instance().Get_Settings(ctx.name) + + # --- 动态加载设置控件 --- + self.load_settings(settings_dict) + + # =================================================================== + # 加载 settings + # =================================================================== + def load_settings(self, settings: Dict[str, BaseSetting]): + # 清空原控件 + while self.scroll_layout.count(): + item = self.scroll_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + self.current_settings_widgets.clear() + + for key, setting in settings.items(): + widget = None + + if isinstance(setting, TextSetting): + widget = QLineEdit() + widget.setText(setting.value) + + elif isinstance(setting, NumberSetting): + widget = QDoubleSpinBox() + widget.setRange(setting.min_value, setting.max_value) + widget.setSingleStep(setting.step) + widget.setValue(setting.value) + + elif isinstance(setting, BoolSetting): + widget = QCheckBox(setting.description) + widget.setChecked(setting.value) + + elif isinstance(setting, SelectSetting): + widget = QComboBox() + widget.addItems(setting.options) + widget.setCurrentText(setting.value) + + else: + continue + + self.current_settings_widgets[key] = widget + self.scroll_layout.addRow(setting.name + ":", widget) + + # =================================================================== + # 保存按钮 + # =================================================================== + def on_save_clicked(self): + ctx = self.plugin_contexts[self.list_widget.currentRow()] + + # --- PluginContext 原样填充 --- + for key, label in self.detail_labels.items(): + value = getattr(ctx, key) + if isinstance(value, list): + value = ", ".join(value) + label.setText(str(value)) + + # --- 你自己的获取 settings 的函数 --- + settings_dict = PluginManager.instance().Get_Settings(ctx.name) + for key, widget in self.current_settings_widgets.items(): + setting = settings_dict[key] + if isinstance(widget, QLineEdit) and isinstance(setting, TextSetting): + setting.value = widget.text() + elif isinstance(widget, QDoubleSpinBox) and isinstance( + setting, NumberSetting + ): + setting.value = widget.value() + elif isinstance(widget, QCheckBox) and isinstance(setting, BoolSetting): + setting.value = widget.isChecked() + elif isinstance(widget, QComboBox) and isinstance(setting, SelectSetting): + setting.value = widget.currentText() + PluginManager.instance().Set_Settings(ctx.name, key, setting) + QMessageBox.information(self, "保存成功", "设置已保存") diff --git a/src/plugins/UpLoadVideo/UpLoadVideo.py b/src/plugins/UpLoadVideo/UpLoadVideo.py new file mode 100644 index 0000000..9808c89 --- /dev/null +++ b/src/plugins/UpLoadVideo/UpLoadVideo.py @@ -0,0 +1,70 @@ +import sys +import os +import msgspec +import zmq + +if getattr(sys, "frozen", False): # 检查是否为pyInstaller生成的EXE + application_path = os.path.dirname(sys.executable) + sys.path.append(application_path + "/../../") + print(application_path + "/../../") +else: + sys.path.append(os.path.dirname(os.path.abspath(__file__)) + "/../../") +from mp_plugins import BasePlugin, BaseConfig +from mp_plugins.base.config import * +from mp_plugins.context import AppContext +from mp_plugins.events import GameEndEvent + + +class UpLoadVideoConfig(BaseConfig): + user: TextSetting + passwd: TextSetting + upload_circle: NumberSetting + auto_upload: BoolSetting + upload_type: SelectSetting + + +class UpLoadVideo(BasePlugin): + def __init__( + self, + ) -> None: + super().__init__() + self._context: AppContext + self._config = UpLoadVideoConfig( + TextSetting("用户名", "user"), + TextSetting("密码", "passwd"), + NumberSetting(name="上传周期", value=0, min_value=1, max_value=10, step=1), + BoolSetting("自动上传", True), + SelectSetting("上传类型", "自动上传", options=["自动上传", "手动上传"]), + ) + self.init_config(self._config) + + def build_plugin_context(self) -> None: + self._plugin_context.name = "UpLoadVideo" + self._plugin_context.display_name = "上传录像" + self._plugin_context.version = "1.0.0" + self._plugin_context.description = "上传录像" + self._plugin_context.author = "LjzLoser" + + def initialize(self) -> None: + return super().initialize() + + def shutdown(self) -> None: + return super().shutdown() + + @BasePlugin.event_handler(GameEndEvent) + def on_game_end(self, event: GameEndEvent) -> GameEndEvent: + return event + + +if __name__ == "__main__": + try: + import sys + + args = sys.argv[1:] + host = args[0] + port = int(args[1]) + plugin = UpLoadVideo() + plugin.run(host, port) + except Exception as e: + with open("UpLoadVideo_error.log", "w", encoding="utf-8") as f: + f.write(str(e)) diff --git a/src/ui/ui_main_board.py b/src/ui/ui_main_board.py index d402449..bdc96aa 100644 --- a/src/ui/ui_main_board.py +++ b/src/ui/ui_main_board.py @@ -229,7 +229,7 @@ def setupUi(self, MainWindow): self.verticalLayout_2.addLayout(self.horizontalLayout_6) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 968, 33)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 968, 27)) font = QtGui.QFont() font.setFamily("微软雅黑") font.setPointSize(12) diff --git a/uiFiles/main_board.ui b/uiFiles/main_board.ui index ca9327f..751e3cb 100644 --- a/uiFiles/main_board.ui +++ b/uiFiles/main_board.ui @@ -609,7 +609,7 @@ 0 0 968 - 33 + 27 @@ -678,6 +678,20 @@ + + + + + Qt::DefaultContextMenu + + + false + + + 查看 + + +