diff --git a/build.bat b/build.bat index edc5ea5..37b572c 100644 --- a/build.bat +++ b/build.bat @@ -25,6 +25,7 @@ echo. echo [3/3] Copy resources to metaminsweeper\ copy /y "%OUT%\plugin_manager\plugin_manager.exe" "%DEST%\" xcopy /e /y /i "%OUT%\plugin_manager\_internal" "%DEST%\_internal" >nul +xcopy /e /y /i "src\plugin_sdk" "%DEST%\plugin_sdk" >nul xcopy /e /y /i "src\plugin_manager" "%DEST%\plugin_manager" >nul xcopy /e /y /i "src\plugins" "%DEST%\plugins" >nul xcopy /e /y /i "src\shared_types" "%DEST%\shared_types" >nul diff --git a/plugin-dev-tutorial.md b/plugin-dev-tutorial.md index 9d22788..edbe4ca 100644 --- a/plugin-dev-tutorial.md +++ b/plugin-dev-tutorial.md @@ -63,17 +63,19 @@ Meta-Minesweeper 采用 **ZMQ 多进程插件架构**: ├── plugin_manager.exe # 插件管理器 ├── plugins/ # 👈 用户插件放这里! │ ├── my_hello.py # 你的插件(单文件) -│ └── my_complex/ # 或包形式插件 -│ ├── __init__.py -│ └── utils.py -├── shared_types/ # 共享类型定义 -│ ├── events.py # 事件类型 -│ ├── commands.py # 指令类型 +│ ├── my_complex/ # 或包形式插件 +│ │ ├── __init__.py +│ │ └── utils.py │ └── services/ # 👈 服务接口定义 │ └── history.py # HistoryService 接口 -├── plugin_manager/ # 插件管理器模块 +├── plugin_sdk/ # 插件开发 SDK │ ├── plugin_base.py # 👈 BasePlugin 基类 -│ └── service_registry.py # 服务注册表 +│ ├── service_registry.py # 服务注册表 +│ └── config_types/ # 配置类型 +├── shared_types/ # 共享类型定义 +│ ├── events.py # 事件类型 +│ └── commands.py # 指令类型 +├── plugin_manager/ # 插件管理器内部模块 ├── user_plugins/ # 备用用户插件目录 ├── data/ │ ├── logs/ # 日志输出(自动创建) @@ -144,6 +146,7 @@ plugins/ ### 3.3 自动发现规则 - 文件/目录名以 `_` 开头的会被跳过(如 `_template.py`) +- `services` 目录会被跳过(它是服务接口定义,不是插件) - 单个 `.py` 文件中可以定义多个继承 `BasePlugin` 的类,都会被加载 - 包形式插件中,只有 `__init__.py` 中导出的 `BasePlugin` 子类会被发现 @@ -167,7 +170,7 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit from PyQt5.QtCore import Qt, pyqtSignal # 导入插件基类和辅助类型 -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode # 导入可用的事件类型 from shared_types.events import VideoSaveEvent @@ -382,7 +385,96 @@ result = self.request(some_query_command, timeout=5.0) | `NewGameCommand` | 开始新游戏 | `rows`, `cols`, `mines` | | `MouseClickCommand` | 模拟鼠标点击 | `row`, `col`, `button`, `modifiers` | -### 5.4 线程安全的 GUI 更新(重要!) +### 5.4 控制授权系统(重要!) + +为了防止多个插件同时发送冲突的控制指令,系统实现了**控制授权机制**: + +- 每个**控制命令类型**只能授权给**一个插件** +- 未获得授权的插件发送该命令会被拒绝 +- 授权变更时会通知相关插件 + +#### 声明需要的控制权限 + +在 `PluginInfo` 中通过 `required_controls` 字段声明: + +```python +from shared_types.commands import NewGameCommand, MouseClickCommand + +class MyPlugin(BasePlugin): + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + description="需要控制权限的插件", + required_controls=[NewGameCommand], # 👈 声明需要的控制权限 + ) +``` + +#### 检查和响应授权状态 + +```python +class MyPlugin(BasePlugin): + + def on_initialized(self) -> None: + # 检查当前是否有权限 + has_auth = self.has_control_auth(NewGameCommand) + self.logger.info(f"NewGameCommand 权限: {has_auth}") + + # 更新 UI 状态 + self.run_on_gui(self._update_ui_auth, has_auth) + + def on_control_auth_changed( + self, + command_type: type, + granted: bool, + ) -> None: + """ + 控制权限变更回调 + + Args: + command_type: 命令类型 + granted: True 表示获得权限,False 表示失去权限 + """ + if command_type == NewGameCommand: + if granted: + self.logger.info("获得了 NewGameCommand 控制权限") + else: + self.logger.warning("失去了 NewGameCommand 控制权限") + # 停止正在进行的操作 + self._stop_auto_play() + + # 更新 UI + self.run_on_gui(self._update_ui_auth, granted) + + def _on_button_click(self) -> None: + # 发送前可以检查权限(不检查也行,无权限时 send_command 会自动拒绝) + if self.has_control_auth(NewGameCommand): + self.send_command(NewGameCommand(rows=16, cols=30, mines=99)) + else: + self.logger.warning("没有 NewGameCommand 权限") +``` + +#### 控制授权相关方法 + +| 方法 | 说明 | +|------|------| +| `has_control_auth(command_type)` | 检查是否有该控制类型的权限 | +| `on_control_auth_changed(cmd_type, granted)` | 权限变更回调(覆写) | +| `PluginInfo.required_controls` | 声明需要的控制权限 | + +#### 用户授权操作 + +用户通过插件管理器工具栏的 **"🔐 控制授权"** 按钮管理授权: + +1. 点击按钮打开授权对话框 +2. 选择要授权的控制类型 +3. 从下拉列表中选择插件(只显示声明了该控制权限的插件) +4. 确认后生效 + +授权配置会持久化到 `data/control_authorization.json`。 + +### 5.5 线程安全的 GUI 更新(重要!) > **为什么需要跨线程机制?** > @@ -466,7 +558,19 @@ if self.has_service(MyService): pass # ════════════════════════════════════════ -# 3. 获取服务代理(推荐) +# 3. 等待服务就绪(推荐) +# ════════════════════════════════════════ +# 如果服务提供者可能在消费者之后初始化,使用 wait_for_service +service = self.wait_for_service(MyService, timeout=10.0) +if service: + # 服务可用 + data = service.get_data(123) +else: + # 服务未就绪 + self.logger.warning("MyService 未就绪") + +# ════════════════════════════════════════ +# 4. 获取服务代理(已知服务存在时) # ════════════════════════════════════════ service = self.get_service_proxy(MyService) @@ -475,7 +579,7 @@ data = service.get_data(123) # 同步调用,阻塞等待结果 all_data = service.list_data(100) # 超时默认 10 秒 # ════════════════════════════════════════ -# 4. 异步调用(非阻塞) +# 5. 异步调用(非阻塞) # ════════════════════════════════════════ future = self.call_service_async(MyService, "get_data", 123) # 做其他事情... @@ -488,7 +592,8 @@ result = future.result(timeout=5.0) # 阻塞等待结果 |------|------| | `register_service(self, protocol=MyService)` | 注册服务(在 `on_initialized` 中调用) | | `has_service(MyService)` | 检查服务是否可用 | -| `get_service_proxy(MyService)` | 获取服务代理对象(推荐) | +| `wait_for_service(MyService, timeout=10.0)` | 等待服务就绪并获取代理(推荐) | +| `get_service_proxy(MyService)` | 获取服务代理对象(已知存在时) | | `call_service_async(MyService, "method", *args)` | 异步调用,返回 Future | **注意事项:** @@ -566,7 +671,7 @@ class PluginInfo: 继承 `OtherInfoBase` 并声明配置字段: ```python -from plugin_manager.config_types import ( +from plugin_sdk import ( OtherInfoBase, BoolConfig, IntConfig, FloatConfig, ChoiceConfig, TextConfig, ColorConfig, FileConfig, PathConfig, LongTextConfig, RangeConfig, @@ -742,9 +847,9 @@ data/plugin_data//config.json 如果预定义的配置类型不满足需求,可以继承 `BaseConfig` 创建自定义类型: ```python -from plugin_manager.config_types.base_config import BaseConfig -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QDial -from PyQt5.QtCore import Qt +from plugin_sdk.config_types import BaseConfig, ConfigWidgetBase, ConfigWidgetWrapper +from PyQt5.QtWidgets import QDial +from typing import Any class DialConfig(BaseConfig[int]): """旋钮配置 → QDial 控件""" @@ -762,8 +867,8 @@ class DialConfig(BaseConfig[int]): self.min_value = min_value self.max_value = max_value - def create_widget(self): - """创建自定义 UI 控件,返回 (控件, getter, setter, 信号)""" + def create_widget(self) -> ConfigWidgetBase: + """创建自定义 UI 控件,返回 ConfigWidgetBase 实例""" widget = QDial() widget.setRange(self.min_value, self.max_value) widget.setValue(int(self.default)) @@ -772,14 +877,23 @@ class DialConfig(BaseConfig[int]): if self.description: widget.setToolTip(self.description) - # 返回控件、getter、setter、以及 valueChanged 信号 - return widget, widget.value, widget.setValue, widget.valueChanged + # 使用 ConfigWidgetWrapper 包装控件 + # 参数:控件, getter, setter, 信号 + return ConfigWidgetWrapper( + widget, + widget.value, # getter + widget.setValue, # setter + widget.valueChanged # 信号 + ) def to_storage(self, value: int) -> int: return int(value) - def from_storage(self, data) -> int: - return int(data) + def from_storage(self, data: Any) -> int: + try: + return int(data) + except (ValueError, TypeError): + return int(self.default) # 使用自定义配置类型 class MyConfig(OtherInfoBase): @@ -792,29 +906,53 @@ class MyConfig(OtherInfoBase): | 方法/属性 | 说明 | |-----------|------| | `widget_type` | 控件类型标识 | -| `create_widget()` | 返回 `(控件, getter, setter, 信号)` 四元组 | +| `create_widget()` | 返回 `ConfigWidgetBase` 实例 | | `to_storage(value)` | 将值转换为 JSON 可序列化格式 | | `from_storage(data)` | 从 JSON 数据恢复值 | -**信号对象说明:** +**ConfigWidgetBase 接口:** + +`create_widget()` 必须返回一个实现了以下接口的对象: + +| 方法/信号 | 说明 | +|-----------|------| +| `get_value() -> Any` | 获取当前值 | +| `set_value(value: Any)` | 设置当前值 | +| `value_change` | `pyqtSignal(object)` 值变化信号 | -信号对象可以是: -- Qt 控件的内置信号(如 `widget.valueChanged`、`widget.textChanged`) -- 自定义 `pyqtSignal`(需要通过 QObject 子类定义) +**使用 ConfigWidgetWrapper:** + +对于简单的包装需求,可以使用 `ConfigWidgetWrapper`: ```python -# 方式一:使用控件的内置信号 -return widget, widget.value, widget.setValue, widget.valueChanged +return ConfigWidgetWrapper(widget, getter, setter, signal) +``` -# 方式二:自定义信号(复杂控件) -from PyQt5.QtCore import QObject, pyqtSignal +**创建自定义 ConfigWidgetBase 子类:** -class MySignal(QObject): - changed = pyqtSignal() +对于复杂控件,可以创建 `ConfigWidgetBase` 的子类: + +```python +from plugin_sdk.config_types import ConfigWidgetBase +from PyQt5.QtCore import pyqtSignal -signal_emitter = MySignal(parent=container) # parent 防止垃圾回收 -# ... 控件变化时调用 signal_emitter.changed.emit() -return container, get_value, set_value, signal_emitter.changed +class MyCustomWidget(ConfigWidgetBase): + """自定义配置控件""" + + # 子类会继承 value_change = pyqtSignal(object) 信号 + + def __init__(self, parent=None): + super().__init__(parent) + # 创建内部控件... + + def get_value(self) -> Any: + """获取当前值""" + return self._internal_widget.value() + + def set_value(self, value: Any) -> None: + """设置当前值""" + self._internal_widget.setValue(value) + self.value_change.emit(value) # 发射信号 ``` --- @@ -842,7 +980,7 @@ from PyQt5.QtWidgets import ( from PyQt5.QtCore import Qt, pyqtSignal, QTimer from PyQt5.QtGui import QFont -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode from shared_types.events import VideoSaveEvent, BoardUpdateEvent @@ -1103,10 +1241,10 @@ def on_initialized(self): #### 1. 定义服务接口 -在 `shared_types/services/` 目录下创建接口定义文件: +在 `plugins/services/` 目录下创建接口定义文件: ```python -# shared_types/services/my_service.py +# plugins/services/my_service.py from typing import Protocol, runtime_checkable from dataclasses import dataclass @@ -1126,6 +1264,8 @@ class MyService(Protocol): #### 2. 服务提供者 ```python +from plugins.services.my_service import MyService, MyData + class ProviderPlugin(BasePlugin): def on_initialized(self): # 注册服务(显式指定 protocol) @@ -1142,16 +1282,36 @@ class ProviderPlugin(BasePlugin): #### 3. 服务使用者 ```python +from plugins.services.my_service import MyService, MyData + class ConsumerPlugin(BasePlugin): def on_initialized(self): - if self.has_service(MyService): - # 获取服务代理(推荐) - self._service = self.get_service_proxy(MyService) + # 方式一:等待服务就绪(推荐) + self._service = self.wait_for_service(MyService, timeout=10.0) + if self._service is None: + self.logger.warning("MyService 未就绪") def _do_something(self): - # 调用服务方法(IDE 完整补全,在服务提供者线程执行) - data = self._service.get_data(123) - all_data = self._service.list_data(100) + if self._service: + # 调用服务方法(IDE 完整补全,在服务提供者线程执行) + data = self._service.get_data(123) + all_data = self._service.list_data(100) +``` + +#### 4. 等待服务就绪 + +如果服务提供者可能在消费者之后初始化,可以使用 `wait_for_service`: + +```python +def on_initialized(self): + # 等待服务就绪,最多 10 秒 + service = self.wait_for_service(MyService, timeout=10.0) + if service: + # 服务可用 + data = service.get_data(123) + else: + # 服务未就绪,可以稍后重试或降级处理 + self.logger.warning("MyService 未就绪") ``` #### 服务调用方式 @@ -1221,10 +1381,11 @@ class ConsumerPlugin(BasePlugin): ```python # ═══ 最小可行插件模板 ═══ -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode -from plugin_manager.config_types import OtherInfoBase, BoolConfig, IntConfig # 可选 +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_sdk import OtherInfoBase, BoolConfig, IntConfig # 配置类型(可选) from shared_types.events import VideoSaveEvent # 按需导入 -from shared_types.services.my_service import MyService # 服务接口(可选) +from shared_types.commands import NewGameCommand # 控制命令(可选) +from plugins.services.my_service import MyService # 服务接口(可选) # ═══ 配置类定义(可选) ═══ class MyConfig(OtherInfoBase): @@ -1241,6 +1402,7 @@ class MyPlugin(BasePlugin): window_mode=WindowMode.TAB, # TAB / DETACHED / CLOSED icon=make_plugin_icon("#1976D2", "M"), other_info=MyConfig, # 👈 绑定配置类(可选) + required_controls=[NewGameCommand], # 👈 声明控制权限(可选) ) def _setup_subscriptions(self) -> None: @@ -1265,11 +1427,19 @@ class MyPlugin(BasePlugin): # 访问配置值(可选) # if self.other_info: # max_count = self.other_info.max_count + + # 检查控制权限(可选) + # has_auth = self.has_control_auth(NewGameCommand) def on_shutdown(self): # 可选:资源清理 pass + def on_control_auth_changed(self, cmd_type: type, granted: bool): + """控制权限变更回调(可选覆写)""" + pass + def _handle_event(self, event): self.logger.info(f"收到事件: {event}") # 用 logger 不用 print # self.run_on_gui(gui_func, *args) # GUI 更新走这 + # self.send_command(NewGameCommand(...)) # 发送控制命令 ``` diff --git a/src/lib_zmq_plugins/server/zmq_server.py b/src/lib_zmq_plugins/server/zmq_server.py index e201fde..1b89858 100644 --- a/src/lib_zmq_plugins/server/zmq_server.py +++ b/src/lib_zmq_plugins/server/zmq_server.py @@ -167,6 +167,8 @@ def _dispatch(self, client_id: bytes, cmd: BaseCommand) -> None: if isinstance(tag, type): tag = tag.__name__ tag = str(tag) + + self._log.info("[Server] 收到命令: tag=%s, request_id=%s", tag, cmd.request_id) if tag == "__sync__": self._handle_sync(client_id, cmd) @@ -179,6 +181,7 @@ def _dispatch(self, client_id: bytes, cmd: BaseCommand) -> None: try: result = handler(cmd) + self._log.info("[Server] handler 执行完成: tag=%s, result=%s", tag, result) except Exception as e: self._log.error("Handler error for %s: %s", tag, e, exc_info=True) if cmd.request_id: diff --git a/src/main.py b/src/main.py index 8e3baba..b92dd7e 100644 --- a/src/main.py +++ b/src/main.py @@ -16,8 +16,9 @@ from utils import get_paths, patch_env # 插件系统(新) -from plugin_manager import GameServerBridge +from plugin_sdk import GameServerBridge from plugin_manager.app_paths import get_env_for_subprocess +from shared_types.commands import NewGameCommand import subprocess os.environ["QT_FONT_DPI"] = "96" @@ -56,7 +57,8 @@ def cli_check_file(file_path: str) -> int: for root, _, files in os.walk(file_path): for file in files: if file.endswith((".evf", ".evfs")): - evf_evfs_files.append(os.path.abspath(os.path.join(root, file))) + evf_evfs_files.append( + os.path.abspath(os.path.join(root, file))) if not evf_evfs_files: result["error"] = "must be evf or evfs files or directory" @@ -80,7 +82,8 @@ def cli_check_file(file_path: str) -> int: checksum = ui.checksum_guard.get_checksum( video.raw_data[: -(len(video.checksum) + 2)] ) - evf_evfs_files[ide] = (e, 0 if list(video.checksum) == list(checksum) else 1) + evf_evfs_files[ide] = (e, 0 if list( + video.checksum) == list(checksum) else 1) elif e.endswith(".evfs"): videos = ms.Evfs(e) try: @@ -92,14 +95,16 @@ def cli_check_file(file_path: str) -> int: evf_evfs_files[ide] = (e, 2) continue - 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 list(videos[0].checksum) != list(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 list(cell.evf_video.checksum) != list(checksum): evf_evfs_files[ide] = (e, 1) @@ -114,7 +119,8 @@ def cli_check_file(file_path: str) -> int: if isinstance(item, tuple) and len(item) == 2 ] - output_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "out.json") + output_file = os.path.join(os.path.dirname( + os.path.abspath(__file__)), "out.json") with open(output_file, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) @@ -155,8 +161,6 @@ def cli_check_file(file_path: str) -> int: ui.mainWindow.show() # ── 启动 ZMQ Server + 插件管理器 ── - game_server = GameServerBridge(ui) - ui.gameServerBridge = game_server # 打包后直接调用 plugin_manager.exe,开发模式用 python -m if getattr(sys, 'frozen', False): @@ -194,10 +198,16 @@ def cli_check_file(file_path: str) -> int: ui._plugin_process = plugin_process # 保存引用,防止被 GC - # 连接信号:插件发来的新游戏指令 → 主线程处理 - game_server.signals.new_game_requested.connect(lambda r, c, m: None) # TODO: 接入游戏逻辑 - - game_server.start() + GameServerBridge.instance().start() + + # 注册控制命令处理器(自动在主线程执行) + def handle_new_game(cmd: NewGameCommand): + print(f"[NewGameCommand] rows={cmd.rows}, cols={cmd.cols}, mines={cmd.mines}") + ui.setBoard_and_start(cmd.rows, cmd.cols, cmd.mines) + from lib_zmq_plugins.shared.base import CommandResponse + return CommandResponse(request_id=cmd.request_id, success=True) + + GameServerBridge.instance().register_handler(NewGameCommand, handle_new_game) # _translate = QtCore.QCoreApplication.translate hwnd = int(ui.mainWindow.winId()) @@ -210,7 +220,7 @@ def cli_check_file(file_path: str) -> int: ) def _cleanup(): - game_server.stop() + GameServerBridge.instance().stop() if plugin_process is not None and plugin_process.poll() is None: plugin_process.terminate() plugin_process.wait(timeout=5) diff --git a/src/mineSweeperGUI.py b/src/mineSweeperGUI.py index b33835d..fbe40ad 100644 --- a/src/mineSweeperGUI.py +++ b/src/mineSweeperGUI.py @@ -6,7 +6,7 @@ # from PyQt5.QtWidgets import QLineEdit, QInputDialog, QShortcut # from PyQt5.QtWidgets import QApplication, QFileDialog, QWidget import gameDefinedParameter -from plugin_manager.server_bridge import GameServerBridge +from plugin_sdk.server_bridge import GameServerBridge from shared_types.events import VideoSaveEvent import superGUI import gameAbout @@ -148,7 +148,6 @@ def save_evf_file_integrated(): # 不带后缀、有绝对路径的、不含最后次数的文件名 # C:/path/zhangsan_20251111_190114_ self.old_evfs_filename = "" - self.gameServerBridge: GameServerBridge = None @property def pixSize(self): @@ -565,11 +564,12 @@ def gameFinished(self): for key in data: if hasattr(ms_board, key): if key == "raw_data": - data[key] = base64.b64encode(ms_board.raw_data).decode("utf-8") + data[key] = base64.b64encode( + ms_board.raw_data).decode("utf-8") continue data[key] = getattr(ms_board, key) event = VideoSaveEvent(**data) - self.gameServerBridge._server.publish(VideoSaveEvent, event) + GameServerBridge.instance().send_event(event) def gameWin(self): # 成功后改脸和状态变量,停时间 self.timer_10ms.stop() @@ -642,7 +642,8 @@ def dump_evf_file_data(self): if not country: country = "XX" elif len(country) == 2 and country.isalpha() and country.isascii(): - file_path = superGUI.resource_path('media') / (country.lower() + ".svg") + file_path = superGUI.resource_path( + 'media') / (country.lower() + ".svg") if os.path.exists(file_path): country = country.upper() elif country in country_name: @@ -1051,7 +1052,8 @@ def set_board_params(self, row, column, minenum): board_key = i break - params = self.predefinedBoardPara[0] if isinstance(board_key, tuple) else self.predefinedBoardPara[board_key] + params = self.predefinedBoardPara[0] if isinstance( + board_key, tuple) else self.predefinedBoardPara[board_key] self.pixSize = params['pixsize'] self.gameMode = params['gamemode'] self.board_constraint = params['board_constraint'] @@ -1402,8 +1404,10 @@ class RECT(ctypes.Structure): def closeEvent_(self): self.unlimit_cursor() - self.game_setting.set_value("DEFAULT/mainWinTop", str(self.mainWindow.y())) - self.game_setting.set_value("DEFAULT/mainWinLeft", str(self.mainWindow.x())) + self.game_setting.set_value( + "DEFAULT/mainWinTop", str(self.mainWindow.y())) + self.game_setting.set_value( + "DEFAULT/mainWinLeft", str(self.mainWindow.x())) self.game_setting.set_value("DEFAULT/row", str(self.row)) self.game_setting.set_value("DEFAULT/column", str(self.column)) self.game_setting.set_value("DEFAULT/minenum", str(self.minenum)) diff --git a/src/plugin_manager/__init__.py b/src/plugin_manager/__init__.py index 461f706..e516aeb 100644 --- a/src/plugin_manager/__init__.py +++ b/src/plugin_manager/__init__.py @@ -7,60 +7,27 @@ - 动态加载插件 - 独立的主界面窗口 - 插件自定义配置系统 + +模块结构: +- plugin_sdk: 插件开发 SDK(BasePlugin, config_types, service_registry) +- plugin_manager: 插件管理器内部实现(PluginManager, EventDispatcher, MainWindow) """ -from .plugin_base import BasePlugin, PluginInfo, make_plugin_icon, WindowMode, LogLevel from .logging_setup import LogConfig from .plugin_manager import PluginManager, run_plugin_manager_process from .event_dispatcher import EventDispatcher from .plugin_loader import PluginLoader -from .server_bridge import GameServerBridge from .main_window import PluginManagerWindow -from .config_types import ( - BaseConfig, - BoolConfig, - IntConfig, - FloatConfig, - ChoiceConfig, - TextConfig, - ColorConfig, - FileConfig, - PathConfig, - LongTextConfig, - RangeConfig, - OtherInfoBase, -) from .config_widget import OtherInfoWidget, OtherInfoScrollArea from .config_manager import PluginConfigManager __all__ = [ - # 核心类 - "BasePlugin", - "PluginInfo", - "WindowMode", - "LogLevel", "LogConfig", - "make_plugin_icon", - # 管理器 "PluginManager", "PluginManagerWindow", "EventDispatcher", "PluginLoader", - "GameServerBridge", "run_plugin_manager_process", - # 配置系统 - "BaseConfig", - "BoolConfig", - "IntConfig", - "FloatConfig", - "ChoiceConfig", - "TextConfig", - "ColorConfig", - "FileConfig", - "PathConfig", - "LongTextConfig", - "RangeConfig", - "OtherInfoBase", "OtherInfoWidget", "OtherInfoScrollArea", "PluginConfigManager", diff --git a/src/plugin_manager/config_manager.py b/src/plugin_manager/config_manager.py index 6f5bbf4..6becdc2 100644 --- a/src/plugin_manager/config_manager.py +++ b/src/plugin_manager/config_manager.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from .config_types.other_info import OtherInfoBase +from plugin_sdk.config_types.other_info import OtherInfoBase if TYPE_CHECKING: pass diff --git a/src/plugin_manager/config_types/color_config.py b/src/plugin_manager/config_types/color_config.py deleted file mode 100644 index 009d4fe..0000000 --- a/src/plugin_manager/config_types/color_config.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -颜色配置类型 → 颜色选择按钮 -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Callable - -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtGui import QColor -from PyQt5.QtWidgets import QPushButton, QColorDialog, QHBoxLayout, QWidget - -from .base_config import BaseConfig - - -class ColorChangeSignal(QObject): - """颜色变化信号发射器""" - changed = pyqtSignal() - - -@dataclass -class ColorConfig(BaseConfig[str]): - """ - 颜色配置 → QPushButton + QColorDialog - - Args: - default: 默认颜色(格式 "#RRGGBB" 或 "#AARRGGBB") - label: 显示标签 - description: tooltip 提示 - - 用法:: - - theme_color = ColorConfig("#1976d2", "主题颜色") - highlight_color = ColorConfig("#ff5722", "高亮颜色") - """ - - widget_type = "color" - - def __post_init__(self) -> None: - """确保默认值是有效的颜色格式""" - if not self.default.startswith("#"): - self.default = "#" + self.default - - def create_widget(self) -> tuple[QWidget, Callable[[], str], Callable[[str], None], QObject]: - """创建颜色选择按钮,返回 (控件, getter, setter, 信号)""" - - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - - # 创建信号发射器,并保存为容器的属性(防止垃圾回收) - signal_emitter = ColorChangeSignal(parent=container) - changed_signal = signal_emitter.changed - - # 颜色预览按钮 - btn = QPushButton() - btn.setFixedSize(40, 24) - btn.setStyleSheet(f"background-color: {self.default}; border: 1px solid #999;") - if self.description: - btn.setToolTip(self.description) - - # 文本显示 - text_btn = QPushButton(self.default) - text_btn.setFixedHeight(24) - text_btn.setStyleSheet("text-align: left; padding-left: 4px;") - - layout.addWidget(btn) - layout.addWidget(text_btn, 1) - - current_color = [self.default] # 使用列表保存可变状态 - - def on_click(): - color = QColorDialog.getColor(QColor(current_color[0])) - if color.isValid(): - color_str = color.name() # #RRGGBB - current_color[0] = color_str - btn.setStyleSheet(f"background-color: {color_str}; border: 1px solid #999;") - text_btn.setText(color_str) - changed_signal.emit() - - btn.clicked.connect(on_click) - text_btn.clicked.connect(on_click) - - def get_value() -> str: - return current_color[0] - - def set_value(value: str) -> None: - if value and value.startswith("#"): - current_color[0] = value - btn.setStyleSheet(f"background-color: {value}; border: 1px solid #999;") - text_btn.setText(value) - - return container, get_value, set_value, changed_signal - - def to_storage(self, value: str) -> str: - """转换为存储格式""" - return str(value) - - def from_storage(self, data: Any) -> str: - """从存储格式恢复""" - if isinstance(data, str) and data.startswith("#"): - return data - return self.default diff --git a/src/plugin_manager/config_types/file_config.py b/src/plugin_manager/config_types/file_config.py deleted file mode 100644 index d82fe6f..0000000 --- a/src/plugin_manager/config_types/file_config.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -文件配置类型 → 文件选择器 -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, Callable - -from PyQt5.QtCore import QObject -from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton, QFileDialog - -from .base_config import BaseConfig - - -@dataclass -class FileConfig(BaseConfig[str]): - """ - 文件配置 → QLineEdit + QPushButton (浏览) - - Args: - default: 默认文件路径 - label: 显示标签 - description: tooltip 提示 - filter: 文件过滤器(如 "JSON Files (*.json)") - save_mode: True 表示保存文件对话框,False 表示打开文件对话框 - - 用法:: - - db_file = FileConfig("", "数据库文件", filter="SQLite (*.db)") - export_file = FileConfig("", "导出文件", filter="JSON (*.json)", save_mode=True) - """ - - filter: str = "" - save_mode: bool = False - - widget_type = "file" - - def __post_init__(self) -> None: - """确保默认值是字符串""" - self.default = str(self.default) if self.default else "" - - def create_widget(self) -> tuple[QWidget, Callable[[], str], Callable[[str], None], QObject]: - """创建文件选择器,返回 (控件, getter, setter, 信号)""" - - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - - line_edit = QLineEdit(str(self.default)) - if self.description: - line_edit.setToolTip(self.description) - - btn = QPushButton("浏览") - btn.setFixedWidth(50) - - def on_browse(): - if self.save_mode: - path, _ = QFileDialog.getSaveFileName( - container, "选择文件", line_edit.text(), self.filter - ) - else: - path, _ = QFileDialog.getOpenFileName( - container, "选择文件", line_edit.text(), self.filter - ) - if path: - line_edit.setText(path) - - btn.clicked.connect(on_browse) - - layout.addWidget(line_edit, 1) - layout.addWidget(btn) - - return container, line_edit.text, line_edit.setText, line_edit.textChanged - - def to_storage(self, value: str) -> str: - """转换为存储格式""" - return str(value) - - def from_storage(self, data: Any) -> str: - """从存储格式恢复""" - return str(data) if data else self.default diff --git a/src/plugin_manager/config_types/path_config.py b/src/plugin_manager/config_types/path_config.py deleted file mode 100644 index 7f683e4..0000000 --- a/src/plugin_manager/config_types/path_config.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -目录配置类型 → 目录选择器 -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Callable - -from PyQt5.QtCore import QObject -from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton, QFileDialog - -from .base_config import BaseConfig - - -@dataclass -class PathConfig(BaseConfig[str]): - """ - 目录配置 → QLineEdit + QPushButton (浏览) - - Args: - default: 默认目录路径 - label: 显示标签 - description: tooltip 提示 - - 用法:: - - log_dir = PathConfig("", "日志目录") - cache_dir = PathConfig("", "缓存目录") - """ - - widget_type = "path" - - def __post_init__(self) -> None: - """确保默认值是字符串""" - self.default = str(self.default) if self.default else "" - - def create_widget(self) -> tuple[QWidget, Callable[[], str], Callable[[str], None], QObject]: - """创建目录选择器,返回 (控件, getter, setter, 信号)""" - - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - - line_edit = QLineEdit(str(self.default)) - if self.description: - line_edit.setToolTip(self.description) - - btn = QPushButton("浏览") - btn.setFixedWidth(50) - - def on_browse(): - path = QFileDialog.getExistingDirectory( - container, "选择目录", line_edit.text() - ) - if path: - line_edit.setText(path) - - btn.clicked.connect(on_browse) - - layout.addWidget(line_edit, 1) - layout.addWidget(btn) - - return container, line_edit.text, line_edit.setText, line_edit.textChanged - - def to_storage(self, value: str) -> str: - """转换为存储格式""" - return str(value) - - def from_storage(self, data: Any) -> str: - """从存储格式恢复""" - return str(data) if data else self.default diff --git a/src/plugin_manager/config_types/range_config.py b/src/plugin_manager/config_types/range_config.py deleted file mode 100644 index 968d5fe..0000000 --- a/src/plugin_manager/config_types/range_config.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -数值范围配置类型 → 两个 QSpinBox -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Callable - -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtWidgets import QWidget, QHBoxLayout, QSpinBox, QLabel - -from .base_config import BaseConfig - - -class RangeChangeSignal(QObject): - """范围变化信号发射器""" - changed = pyqtSignal() - - -@dataclass -class RangeConfig(BaseConfig[tuple[int, int]]): - """ - 数值范围配置 → 两个 QSpinBox - - Args: - default: 默认范围值 (min, max) - label: 显示标签 - description: tooltip 提示 - min_value: 最小允许值 - max_value: 最大允许值 - step: 步进值 - - 用法:: - - time_range = RangeConfig((0, 300), "时间范围(秒)", min_value=0, max_value=999) - bbbv_range = RangeConfig((0, 999), "3BV范围", min_value=0, max_value=9999) - """ - - min_value: int = 0 - max_value: int = 9999 - step: int = 1 - - widget_type = "range" - - def __post_init__(self) -> None: - """确保默认值是元组""" - if not isinstance(self.default, tuple): - self.default = (self.min_value, self.max_value) - self.default = (int(self.default[0]), int(self.default[1])) - - def create_widget( - self, - ) -> tuple[QWidget, Callable[[], tuple[int, int]], Callable[[tuple[int, int]], None], QObject]: - """创建范围选择器,返回 (控件, getter, setter, 信号)""" - - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - - # 创建信号发射器,并保存为容器的属性(防止垃圾回收) - signal_emitter = RangeChangeSignal(parent=container) - changed_signal = signal_emitter.changed - - # 最小值 - min_spin = QSpinBox() - min_spin.setRange(self.min_value, self.max_value) - min_spin.setValue(self.default[0]) - min_spin.setSingleStep(self.step) - - # 分隔符 - sep = QLabel("-") - - # 最大值 - max_spin = QSpinBox() - max_spin.setRange(self.min_value, self.max_value) - max_spin.setValue(self.default[1]) - max_spin.setSingleStep(self.step) - - if self.description: - min_spin.setToolTip(self.description) - max_spin.setToolTip(self.description) - - layout.addWidget(min_spin) - layout.addWidget(sep) - layout.addWidget(max_spin) - - # 连接两个 spinbox 的值变化信号到统一的 changed 信号 - min_spin.valueChanged.connect(lambda: changed_signal.emit()) - max_spin.valueChanged.connect(lambda: changed_signal.emit()) - - def get_value() -> tuple[int, int]: - return (min_spin.value(), max_spin.value()) - - def set_value(value: tuple[int, int]) -> None: - if isinstance(value, tuple) and len(value) == 2: - min_spin.setValue(int(value[0])) - max_spin.setValue(int(value[1])) - - return container, get_value, set_value, changed_signal - - def to_storage(self, value: tuple[int, int]) -> list[int]: - """转换为存储格式(JSON 不支持元组)""" - return [int(value[0]), int(value[1])] - - def from_storage(self, data: Any) -> tuple[int, int]: - """从存储格式恢复""" - if isinstance(data, (list, tuple)) and len(data) == 2: - return (int(data[0]), int(data[1])) - return self.default diff --git a/src/plugin_manager/config_widget.py b/src/plugin_manager/config_widget.py index 98e70dc..3b4e6a4 100644 --- a/src/plugin_manager/config_widget.py +++ b/src/plugin_manager/config_widget.py @@ -6,19 +6,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING -from PyQt5.QtCore import Qt, QObject, pyqtSignal +from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import ( QFormLayout, QLabel, QScrollArea, - QVBoxLayout, QWidget, ) -from .config_types.base_config import BaseConfig -from .config_types.other_info import OtherInfoBase +from plugin_sdk.config_types.base_config import ConfigWidgetBase +from plugin_sdk.config_types.other_info import OtherInfoBase if TYPE_CHECKING: pass @@ -46,10 +45,7 @@ def __init__(self, other_info: OtherInfoBase, parent: QWidget | None = None) -> """ super().__init__(parent) self._other_info = other_info - self._widgets: dict[str, QWidget] = {} - self._getters: dict[str, Callable[[], Any]] = {} - self._setters: dict[str, Callable[[Any], None]] = {} - self._signals: dict[str, QObject] = {} + self._widgets: dict[str, ConfigWidgetBase] = {} self._setup_ui() @@ -72,11 +68,11 @@ def _setup_ui(self) -> None: for name, config_field in fields.items(): # 使用 config_field 自己的 create_widget 方法 - widget, getter, setter, signal = config_field.create_widget() + widget = config_field.create_widget() # 设置当前值 current = getattr(self._other_info, name) - setter(current) + widget.set_value(current) # 创建标签 label = QLabel(config_field.label) @@ -86,29 +82,9 @@ def _setup_ui(self) -> None: # 保存引用 self._widgets[name] = widget - self._getters[name] = getter - self._setters[name] = setter - self._signals[name] = signal # 连接变化信号 - self._connect_change_signal(signal, name) - - def _connect_change_signal(self, signal: QObject, name: str) -> None: - """ - 连接控件变化信号 - - Args: - signal: 值变化信号对象(QObject 或 pyqtSignal) - name: 字段名 - """ - def on_change(*args) -> None: - self._on_changed(name) - - # 信号可能是 QObject(有 connect 方法)或信号的 bound signal - try: - signal.connect(on_change) - except (TypeError, AttributeError): - pass # 如果信号连接失败,忽略 + widget.value_change.connect(lambda *_, n=name: self._on_changed(n)) def _on_changed(self, name: str) -> None: """ @@ -117,22 +93,23 @@ def _on_changed(self, name: str) -> None: Args: name: 字段名 """ - value = self._getters[name]() + widget = self._widgets[name] + value = widget.get_value() # 只发射 UI 信号,不修改配置对象 # 配置将在 apply_to_config() 时统一应用 self.config_changed.emit(name, value) def apply_to_config(self) -> None: """将所有 UI 值同步到 OtherInfo 配置对象(此时才触发变化回调)""" - for name, getter in self._getters.items(): + for name, widget in self._widgets.items(): # 设置配置值,此时会触发 OtherInfoBase 的变化回调 - setattr(self._other_info, name, getter()) + setattr(self._other_info, name, widget.get_value()) def refresh_from_config(self) -> None: """从 OtherInfo 配置对象刷新 UI 值""" - for name, setter in self._setters.items(): + for name, widget in self._widgets.items(): value = getattr(self._other_info, name) - setter(value) + widget.set_value(value) @property def other_info(self) -> OtherInfoBase: diff --git a/src/plugin_manager/event_dispatcher.py b/src/plugin_manager/event_dispatcher.py index cc6f8e4..5011b2d 100644 --- a/src/plugin_manager/event_dispatcher.py +++ b/src/plugin_manager/event_dispatcher.py @@ -15,10 +15,10 @@ import loguru -from .service_registry import ServiceRegistry +from plugin_sdk.service_registry import ServiceRegistry if TYPE_CHECKING: - from .plugin_base import BasePlugin + from plugin_sdk.plugin_base import BasePlugin logger = loguru.logger.bind(name="EventDispatcher") diff --git a/src/plugin_manager/main_window.py b/src/plugin_manager/main_window.py index 81ddfd9..2ecf88f 100644 --- a/src/plugin_manager/main_window.py +++ b/src/plugin_manager/main_window.py @@ -22,6 +22,7 @@ QFormLayout, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QListWidget, QListWidgetItem, @@ -34,14 +35,18 @@ QStatusBar, QSystemTrayIcon, QTabBar, + QTableWidget, + QTableWidgetItem, QTabWidget, + QToolBar, QVBoxLayout, QWidget, QDialog, ) from .plugin_state import PluginStateManager, PluginState -from .plugin_base import PluginLifecycle, WindowMode, LogLevel +from plugin_sdk.plugin_base import PluginLifecycle, WindowMode, LogLevel +from plugin_sdk.control_auth import ControlAuthorizationManager from .app_paths import get_data_dir if TYPE_CHECKING: @@ -72,6 +77,8 @@ def __init__(self, plugin_name: str, widget: QWidget, parent=None): self._plugin_name = plugin_name self._widget = widget self._icon: QIcon | None = None + self._dragging = False + self._drag_offset = QPoint() self.setWindowTitle(f"{plugin_name} - {self.tr('插件')}") self.setMinimumSize(400, 300) @@ -99,6 +106,31 @@ def __init__(self, plugin_name: str, widget: QWidget, parent=None): layout.addWidget(widget) widget.setVisible(True) widget.show() + + def start_drag(self, global_pos: QPoint) -> None: + """启动拖拽模式(从外部调用)""" + self._dragging = True + # 计算鼠标相对于窗口左上角的偏移 + self._drag_offset = global_pos - self.pos() + # 捕获鼠标 + self.grabMouse() + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.LeftButton: + self._dragging = True + self._drag_offset = event.globalPos() - self.pos() + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + if self._dragging and event.buttons() & Qt.LeftButton: + self.move(event.globalPos() - self._drag_offset) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.LeftButton: + self._dragging = False + self.releaseMouse() + super().mouseReleaseEvent(event) def closeEvent(self, event) -> None: """关闭时发出嵌入请求信号,不销毁 widget""" @@ -210,8 +242,18 @@ def _detach_tab(self, index: int, name: str, pos: QPoint | None = None) -> None: self._detached_windows[name] = window if pos is not None: - window.move(pos) - window.show() + # 先显示窗口以获取正确的尺寸 + window.show() + # 将标题栏中心移动到鼠标位置 + # 水平居中,垂直方向标题栏高度约 30px + title_height = window.frameGeometry().height() - window.height() + new_x = pos.x() - window.width() // 2 + new_y = pos.y() - title_height // 2 + window.move(new_x, new_y) + # 启动拖拽模式,让窗口跟随鼠标 + window.start_drag(pos) + else: + window.show() window.activateWindow() self.tab_detached.emit(name) @@ -455,6 +497,182 @@ def apply_config(self) -> None: self._config_widget.apply_to_config() +# ═══════════════════════════════════════════════════════════════════ +# 控制授权配置对话框 +# ═══════════════════════════════════════════════════════════════════ + +class ControlAuthorizationDialog(QDialog): + """ + 控制授权配置对话框 + + 管理插件对控制命令的使用权限。 + 只显示声明了需要该控制权限的插件。 + """ + + def __init__( + self, + plugin_controls: dict[str, list[type]], + parent=None, + ): + """ + Args: + plugin_controls: {plugin_name: [command_type, ...]} + 插件声明需要的控制权限 + """ + super().__init__(parent) + self._plugin_controls = plugin_controls + self._auth_manager = ControlAuthorizationManager.instance() + + self.setWindowTitle(self.tr("控制授权配置")) + self.setMinimumWidth(500) + self.setMinimumHeight(300) + + self._setup_ui() + self._load_authorizations() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + + # 说明文字 + info_label = QLabel(self.tr( + "每个控制命令只能授权给一个插件。\n" + "未授权的控制命令,所有插件都不能使用。\n" + "下拉列表仅显示声明了该权限的插件。" + )) + info_label.setStyleSheet("color: gray; padding: 8px;") + layout.addWidget(info_label) + + # 表格 + self._table = QTableWidget() + self._table.setColumnCount(3) + self._table.setHorizontalHeaderLabels([ + self.tr("控制命令"), + self.tr("授权插件"), + self.tr("状态"), + ]) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed) + self._table.setColumnWidth(2, 80) + self._table.setEditTriggers(QTableWidget.NoEditTriggers) + self._table.setAlternatingRowColors(True) + layout.addWidget(self._table) + + # 按钮 + btns = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + btns.accepted.connect(self._on_accept) + btns.rejected.connect(self.reject) + layout.addWidget(btns) + + def _load_authorizations(self) -> None: + """加载授权状态到表格""" + control_types = self._auth_manager.get_all_control_types() + status = self._auth_manager.get_authorization_status() + + self._table.setRowCount(len(control_types)) + self._combos: list[QComboBox] = [] + + for row, cmd_type in enumerate(control_types): + try: + tag = self._auth_manager._get_tag(cmd_type) + except ValueError: + continue + + # 控制命令名称 + name_item = QTableWidgetItem(cmd_type.__name__) + name_item.setData(Qt.UserRole, cmd_type) # 存储类型 + self._table.setItem(row, 0, name_item) + + # 找出声明了该控制权限的插件 + eligible_plugins = [ + plugin_name + for plugin_name, controls in self._plugin_controls.items() + if any( + self._is_same_command_type(cmd_type, c) + for c in controls + ) + ] + + # 插件下拉框 + combo = QComboBox() + combo.addItem(self.tr("未授权"), None) # index 0 = 未授权 + + if eligible_plugins: + for plugin_name in sorted(eligible_plugins): + combo.addItem(plugin_name, plugin_name) + else: + # 没有插件声明需要该权限,禁用下拉框 + combo.setEnabled(False) + + # 设置当前值 + current_plugin = status.get(tag) + if current_plugin and current_plugin in eligible_plugins: + idx = combo.findData(current_plugin) + if idx >= 0: + combo.setCurrentIndex(idx) + + self._table.setCellWidget(row, 1, combo) + self._combos.append(combo) + + # 状态显示 + self._update_status(row, current_plugin, eligible_plugins) + + # 连接下拉框变化 + combo.currentIndexChanged.connect(lambda _, r=row: self._on_combo_changed(r)) + + def _is_same_command_type(self, type1: type, type2: type) -> bool: + """判断两个命令类型是否相同(通过 tag)""" + try: + tag1 = self._auth_manager._get_tag(type1) + tag2 = self._auth_manager._get_tag(type2) + return tag1 == tag2 + except ValueError: + return type1 is type2 + + def _update_status( + self, + row: int, + plugin_name: str | None, + eligible_plugins: list[str], + ) -> None: + """更新状态列""" + if not eligible_plugins: + status_item = QTableWidgetItem(self.tr("无申请")) + status_item.setForeground(QColor("#9e9e9e")) + elif plugin_name: + status_item = QTableWidgetItem(self.tr("● 已授权")) + status_item.setForeground(QColor("#4caf50")) + else: + status_item = QTableWidgetItem(self.tr("○ 未授权")) + status_item.setForeground(QColor("#ff9800")) + self._table.setItem(row, 2, status_item) + + def _on_combo_changed(self, row: int) -> None: + """下拉框变化时更新状态""" + combo = self._table.cellWidget(row, 1) + plugin_name = combo.currentData() + self._update_status(row, plugin_name, []) # 简化,不重新计算 eligible_plugins + + def _on_accept(self) -> None: + """确定按钮:保存授权""" + for row in range(self._table.rowCount()): + name_item = self._table.item(row, 0) + combo = self._table.cellWidget(row, 1) + + cmd_type = name_item.data(Qt.UserRole) + plugin_name = combo.currentData() + + if plugin_name is None: + self._auth_manager.revoke(cmd_type) + else: + self._auth_manager.authorize(cmd_type, plugin_name) + + self._auth_manager.save() + self.accept() + + # ═══════════════════════════════════════════════════════════════════ # 主窗口 # ═══════════════════════════════════════════════════════════════════ @@ -498,7 +716,41 @@ def __init__(self, plugin_manager: PluginManager, parent=None): def _setup_ui(self) -> None: """构建界面""" - # 主布局:左侧插件列表 + 右侧标签页 + # ── 工具栏 ── + toolbar = QToolBar(self.tr("工具栏")) + toolbar.setMovable(False) + self.addToolBar(toolbar) + + # 连接按钮 + btn = QPushButton(self.tr("连接")) + btn.setCheckable(True) + btn.setToolTip(self.tr("连接/断开主进程")) + self._conn_btn = btn + toolbar.addWidget(btn) + + toolbar.addSeparator() + + # 刷新按钮 + self._refresh_btn = QPushButton(self.tr("刷新")) + self._refresh_btn.setToolTip(self.tr("刷新插件列表")) + toolbar.addWidget(self._refresh_btn) + + toolbar.addSeparator() + + # 调试按钮 + self._debug_btn = QPushButton("🐛 Debug") + self._debug_btn.setCheckable(True) + self._debug_btn.setToolTip(self.tr("开启/关闭远程调试 (debugpy)")) + toolbar.addWidget(self._debug_btn) + + toolbar.addSeparator() + + # 控制授权按钮 + self._control_auth_btn = QPushButton("🔐 " + self.tr("控制授权")) + self._control_auth_btn.setToolTip(self.tr("配置插件控制命令权限")) + toolbar.addWidget(self._control_auth_btn) + + # ── 主布局:左侧插件列表 + 右侧标签页 ── main_splitter = QWidget() main_layout = QHBoxLayout(main_splitter) main_layout.setContentsMargins(0, 0, 0, 0) @@ -509,15 +761,10 @@ def _setup_ui(self) -> None: left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(4, 4, 4, 4) - # 连接状态行 - conn_row = QHBoxLayout() - conn_row.addWidget(QLabel(self.tr("主进程:"))) - btn = QPushButton(self.tr("连接")) - btn.setCheckable(True) - self._conn_btn = btn - conn_row.addWidget(btn) - conn_row.addStretch() - left_layout.addLayout(conn_row) + # 标题 + title_label = QLabel(self.tr("插件列表")) + title_label.setStyleSheet("font-weight: bold; padding: 4px;") + left_layout.addWidget(title_label) # 插件列表 lst = QListWidget() @@ -528,19 +775,6 @@ def _setup_ui(self) -> None: self._list = lst left_layout.addWidget(lst) - # 刷新 + 调试按钮行 - btn_row = QHBoxLayout() - self._refresh_btn = QPushButton(self.tr("刷新")) - btn_row.addWidget(self._refresh_btn) - btn_row.addStretch() - - self._debug_btn = QPushButton("🐛 Debug") - self._debug_btn.setCheckable(True) - self._debug_btn.setToolTip(self.tr("开启/关闭远程调试 (debugpy)")) - btn_row.addWidget(self._debug_btn) - - left_layout.addLayout(btn_row) - left_panel.setMaximumWidth(200) main_layout.addWidget(left_panel) @@ -637,6 +871,23 @@ def _connect_signals(self) -> None: # 调试开关 self._debug_btn.toggled.connect(self._toggle_debug) + # 控制授权按钮 + self._control_auth_btn.clicked.connect(self._open_control_auth_dialog) + + def _open_control_auth_dialog(self) -> None: + """打开控制授权配置对话框""" + # 获取插件声明需要的控制权限 + plugin_controls: dict[str, list[type]] = {} + + for p in self._manager.plugins.values(): + if p.lifecycle == PluginLifecycle.READY: + required = p.info.required_controls or [] + if required: + plugin_controls[p.name] = required + + dialog = ControlAuthorizationDialog(plugin_controls, self) + dialog.exec_() + # ── 连接状态 ──────────────────────────────────────── def set_connected(self, ok: bool) -> None: diff --git a/src/plugin_manager/plugin_loader.py b/src/plugin_manager/plugin_loader.py index 1f4977d..752b0dc 100644 --- a/src/plugin_manager/plugin_loader.py +++ b/src/plugin_manager/plugin_loader.py @@ -13,7 +13,7 @@ import loguru if TYPE_CHECKING: - from .plugin_base import BasePlugin, PluginInfo + from plugin_sdk.plugin_base import BasePlugin, PluginInfo logger = loguru.logger.bind(name="PluginLoader") @@ -21,84 +21,100 @@ class PluginLoader: """ 插件加载器 - + 功能: - 从目录发现插件模块 - 动态导入插件 - 实例化插件类 """ - + def __init__(self, plugin_dirs: list[str | Path] | None = None): self._plugin_dirs: list[Path] = [] + self._added_paths: set[Path] = set() # 已添加到 sys.path 的目录 if plugin_dirs: for d in plugin_dirs: self.add_plugin_dir(d) - + def add_plugin_dir(self, path: str | Path) -> None: """添加插件搜索目录""" p = Path(path) if p.is_dir(): self._plugin_dirs.append(p) + # 将插件目录的父目录加入 sys.path(支持 from plugins.xxx 导入) + parent = p.parent + if parent not in self._added_paths: + self._added_paths.add(parent) + parent_str = str(parent) + if parent_str not in sys.path: + sys.path.insert(0, parent_str) + logger.debug(f"Added to sys.path: {parent}") logger.debug(f"Added plugin directory: {p}") else: logger.warning(f"Plugin directory not found: {p}") - + def discover_plugins(self) -> list[tuple[Path, str]]: """发现所有插件模块""" plugins = [] - + + # 排除的目录名(不是插件,但放在 plugins 目录下) + excluded_dirs = {"services"} + for plugin_dir in self._plugin_dirs: if not plugin_dir.is_dir(): continue - + # 单文件插件 for py_file in plugin_dir.glob("*.py"): if py_file.name.startswith("_"): continue plugins.append((py_file, py_file.stem)) logger.debug(f"Discovered plugin module: {py_file}") - + # 包形式插件 for pkg_dir in plugin_dir.iterdir(): if pkg_dir.is_dir() and (pkg_dir / "__init__.py").exists(): - if not pkg_dir.name.startswith("_"): - plugins.append((pkg_dir / "__init__.py", pkg_dir.name)) - logger.debug(f"Discovered plugin package: {pkg_dir}") - + # 排除 services 等非插件目录 + if pkg_dir.name.startswith("_") or pkg_dir.name in excluded_dirs: + continue + plugins.append((pkg_dir / "__init__.py", pkg_dir.name)) + logger.debug(f"Discovered plugin package: {pkg_dir}") + return plugins - + def load_module(self, module_path: Path, module_name: str) -> object | None: """动态加载模块""" try: if module_name in sys.modules: return sys.modules[module_name] - - spec = importlib.util.spec_from_file_location(module_name, module_path) + + spec = importlib.util.spec_from_file_location( + module_name, module_path) if spec is None or spec.loader is None: logger.error(f"Failed to create spec for: {module_path}") return None - + module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) - + logger.info(f"Loaded plugin module: {module_name}") return module - + except Exception as e: - logger.error(f"Failed to load module {module_path}: {e}", exc_info=True) + logger.error( + f"Failed to load module {module_path}: {e}", exc_info=True) return None - + def get_plugin_classes(self, module: object) -> list[type[BasePlugin]]: """从模块中提取插件类""" - from .plugin_base import BasePlugin - + from plugin_sdk.plugin_base import BasePlugin + plugin_classes: list[type[BasePlugin]] = [] - + for name in dir(module): if name.startswith("_"): continue - + obj = getattr(module, name) if ( isinstance(obj, type) @@ -107,9 +123,9 @@ def get_plugin_classes(self, module: object) -> list[type[BasePlugin]]: ): plugin_classes.append(obj) logger.debug(f"Found plugin class: {obj.__name__}") - + return plugin_classes - + def load_plugins_from_module( self, module_path: Path, @@ -117,13 +133,13 @@ def load_plugins_from_module( ) -> list[BasePlugin]: """从模块加载所有插件实例""" plugins: list[BasePlugin] = [] - + module = self.load_module(module_path, module_name) if module is None: return plugins - + plugin_classes = self.get_plugin_classes(module) - + for plugin_class in plugin_classes: try: info = self._get_plugin_info(plugin_class) @@ -135,35 +151,36 @@ def load_plugins_from_module( f"Failed to instantiate plugin {plugin_class.__name__}: {e}", exc_info=True, ) - + return plugins - + def _get_plugin_info(self, plugin_class: type[BasePlugin]) -> PluginInfo: """获取插件的元信息""" return plugin_class.plugin_info() - + def load_all(self) -> list[BasePlugin]: """加载所有发现的插件""" all_plugins: list[BasePlugin] = [] - + discovered = self.discover_plugins() for module_path, module_name in discovered: plugins = self.load_plugins_from_module(module_path, module_name) all_plugins.extend(plugins) - + logger.info(f"Loaded {len(all_plugins)} plugin(s)") return all_plugins - + def reload_module(self, module_name: str) -> bool: """重新加载模块""" if module_name not in sys.modules: logger.warning(f"Module not loaded: {module_name}") return False - + try: importlib.reload(sys.modules[module_name]) logger.info(f"Reloaded module: {module_name}") return True except Exception as e: - logger.error(f"Failed to reload module {module_name}: {e}", exc_info=True) - return False \ No newline at end of file + logger.error( + f"Failed to reload module {module_name}: {e}", exc_info=True) + return False diff --git a/src/plugin_manager/plugin_manager.py b/src/plugin_manager/plugin_manager.py index 63c37d4..68726b3 100644 --- a/src/plugin_manager/plugin_manager.py +++ b/src/plugin_manager/plugin_manager.py @@ -18,7 +18,7 @@ from shared_types import EVENT_TYPES, COMMAND_TYPES from .event_dispatcher import EventDispatcher -from .plugin_base import BasePlugin +from plugin_sdk.plugin_base import BasePlugin from .plugin_loader import PluginLoader from .app_paths import get_all_plugin_dirs, patch_sys_path_for_frozen @@ -144,10 +144,29 @@ def start(self) -> None: self._client.connect() self._setup_zmq_subscriptions() self._initialize_plugins() + self._validate_control_authorizations() self._started = True logger.info("Plugin manager started") + def _validate_control_authorizations(self) -> None: + """验证控制授权配置,清除无效插件的授权""" + from plugin_sdk.control_auth import ControlAuthorizationManager + + auth_manager = ControlAuthorizationManager.instance() + + # 获取已加载的插件名称 + loaded_plugins = set(self._plugins.keys()) + + # 验证并清除无效授权 + removed = auth_manager.validate_authorizations(loaded_plugins) + + if removed: + logger.warning(f"控制授权已清除(插件未加载): {removed}") + + # 保存更新后的配置 + auth_manager.save() + def stop(self) -> None: """停止插件管理器""" if not self._started: diff --git a/src/plugin_manager/plugin_state.py b/src/plugin_manager/plugin_state.py index a748275..8ae0002 100644 --- a/src/plugin_manager/plugin_state.py +++ b/src/plugin_manager/plugin_state.py @@ -14,7 +14,7 @@ from typing import Any import loguru -from .plugin_base import WindowMode, LogLevel +from plugin_sdk.plugin_base import WindowMode, LogLevel logger = loguru.logger.bind(name="PluginState") @@ -45,12 +45,15 @@ def __init__(self, file_path: str | Path): def load(self) -> None: """从 JSON 文件加载状态""" if not self._file.exists(): - logger.info(f"State file not found: {self._file} (will create on save)") + logger.info( + f"State file not found: {self._file} (will create on save)") return try: - raw: dict[str, dict[str, Any]] = json.loads(self._file.read_text("utf-8")) + raw: dict[str, dict[str, Any]] = json.loads( + self._file.read_text("utf-8")) for name, d in raw.items(): - self._states[name] = PluginState(**{k: v for k, v in d.items() if k in asdict(_DEFAULT)}) + self._states[name] = PluginState( + **{k: v for k, v in d.items() if k in asdict(_DEFAULT)}) logger.info(f"Loaded state for {len(self._states)} plugin(s)") except Exception as e: logger.error(f"Failed to load state from {self._file}: {e}") diff --git a/src/plugin_manager/server_bridge.py b/src/plugin_manager/server_bridge.py deleted file mode 100644 index 441ae2e..0000000 --- a/src/plugin_manager/server_bridge.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -ZMQ Server 集成模块 - -提供将 ZMQ Server 集成到扫雷主进程的便捷方法 -""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from PyQt5.QtCore import QObject, pyqtSignal -from lib_zmq_plugins.server.zmq_server import ZMQServer -from lib_zmq_plugins.shared.base import CommandResponse - -from shared_types import EVENT_TYPES, COMMAND_TYPES -from shared_types import ( - BoardUpdateEvent, - NewGameCommand, -) - -if TYPE_CHECKING: - from lib_zmq_plugins.log import LogHandler - -import loguru -logger = loguru.logger.bind(name="ServerBridge") - - -class GameServerSignals(QObject): - """用于跨线程通信的信号""" - new_game_requested = pyqtSignal(int, int, int) # rows, cols, mines - - -class GameServerBridge: - """游戏服务端桥接器""" - - def __init__( - self, - game_ui: Any, - endpoint: str | None = None, - log_handler: LogHandler | None = None, - ): - self._game_ui = game_ui - self._log = log_handler - - # 信号对象,用于跨线程调用 - self._signals = GameServerSignals() - - # 默认端点 - if endpoint is None: - endpoint = "tcp://127.0.0.1:5555" - - self._endpoint = endpoint - self._server = ZMQServer(endpoint=endpoint, log_handler=log_handler) - - # 注册类型 - self._server.register_event_types(*EVENT_TYPES) - self._server.register_command_types(*COMMAND_TYPES) - - # 注册指令处理器 - self._server.register_handler(NewGameCommand, self._handle_new_game) - - @property - def endpoint(self) -> str: - return self._endpoint - - @property - def signals(self) -> GameServerSignals: - """获取信号对象,用于连接到主线程的槽函数""" - return self._signals - - def start(self) -> None: - """启动服务""" - self._server.start() - logger.info(f"Game server bridge started at {self._endpoint}") - - def stop(self) -> None: - """停止服务""" - self._server.stop() - logger.info("Game server bridge stopped") - - # ═══════════════════════════════════════════════════════════════════ - # 指令处理 - # ═══════════════════════════════════════════════════════════════════ - - def _handle_new_game(self, cmd: NewGameCommand) -> CommandResponse: - """处理新游戏指令(在 ZMQ 后台线程中运行)""" - try: - # 通过信号发送到主线程执行 - self._signals.new_game_requested.emit(cmd.rows, cmd.cols, cmd.mines) - return CommandResponse(request_id=cmd.request_id, success=True) - except Exception as e: - logger.error(f"New game error: {e}", exc_info=True) - return CommandResponse(request_id=cmd.request_id, success=False, error=str(e)) diff --git a/src/plugin_sdk/__init__.py b/src/plugin_sdk/__init__.py new file mode 100644 index 0000000..70b2cd2 --- /dev/null +++ b/src/plugin_sdk/__init__.py @@ -0,0 +1,78 @@ +""" +插件 SDK + +提供给插件开发者使用的模块: +- BasePlugin: 插件基类 +- PluginInfo: 插件信息 +- config_types: 配置类型 +- service_registry: 服务注册 +- server_bridge: 服务端桥接(主进程使用) +- control_auth: 控制授权管理 +""" + +from .plugin_base import ( + BasePlugin, + PluginInfo, + PluginLifecycle, + WindowMode, + LogLevel, + make_plugin_icon, +) +from .service_registry import ( + ServiceRegistry, + ServiceNotFoundError, + ServiceAlreadyRegisteredError, +) +from .server_bridge import GameServerBridge +from .control_auth import ControlAuthorizationManager + +# 配置类型 +from .config_types import ( + BaseConfig, + ConfigWidgetBase, + ConfigWidgetWrapper, + OtherInfoBase, + BoolConfig, + IntConfig, + FloatConfig, + ChoiceConfig, + TextConfig, + ColorConfig, + FileConfig, + PathConfig, + LongTextConfig, + RangeConfig, +) + +__all__ = [ + # 插件基类 + "BasePlugin", + "PluginInfo", + "PluginLifecycle", + "WindowMode", + "LogLevel", + "make_plugin_icon", + # 服务注册 + "ServiceRegistry", + "ServiceNotFoundError", + "ServiceAlreadyRegisteredError", + # 服务端桥接 + "GameServerBridge", + # 控制授权 + "ControlAuthorizationManager", + # 配置类型 + "BaseConfig", + "ConfigWidgetBase", + "ConfigWidgetWrapper", + "OtherInfoBase", + "BoolConfig", + "IntConfig", + "FloatConfig", + "ChoiceConfig", + "TextConfig", + "ColorConfig", + "FileConfig", + "PathConfig", + "LongTextConfig", + "RangeConfig", +] diff --git a/src/plugin_manager/config_types/__init__.py b/src/plugin_sdk/config_types/__init__.py similarity index 92% rename from src/plugin_manager/config_types/__init__.py rename to src/plugin_sdk/config_types/__init__.py index 2983dd9..ec20966 100644 --- a/src/plugin_manager/config_types/__init__.py +++ b/src/plugin_sdk/config_types/__init__.py @@ -22,7 +22,7 @@ class MyPluginOtherInfo(OtherInfoBase): time_range = RangeConfig((0, 300), "时间范围(秒)") """ -from .base_config import BaseConfig +from .base_config import BaseConfig, ConfigWidgetBase, ConfigWidgetWrapper from .bool_config import BoolConfig from .int_config import IntConfig from .float_config import FloatConfig @@ -37,6 +37,8 @@ class MyPluginOtherInfo(OtherInfoBase): __all__ = [ "BaseConfig", + "ConfigWidgetBase", + "ConfigWidgetWrapper", "BoolConfig", "IntConfig", "FloatConfig", diff --git a/src/plugin_manager/config_types/base_config.py b/src/plugin_sdk/config_types/base_config.py similarity index 50% rename from src/plugin_manager/config_types/base_config.py rename to src/plugin_sdk/config_types/base_config.py index 1e82f4e..3d4ed68 100644 --- a/src/plugin_manager/config_types/base_config.py +++ b/src/plugin_sdk/config_types/base_config.py @@ -16,6 +16,67 @@ T = TypeVar("T") +class ConfigWidgetBase(QWidget): + """ + 配置控件基类 + + 自定义配置类型的控件必须继承此类,并实现以下内容: + - get_value(): 获取当前值 + - set_value(value): 设置当前值 + - value_change 信号: 值变化时发射(已提供默认实现) + """ + + value_change = pyqtSignal(object) + + def get_value(self) -> Any: + """获取当前值""" + raise NotImplementedError("子类必须实现 get_value 方法") + + def set_value(self, value: Any) -> None: + """设置当前值""" + raise NotImplementedError("子类必须实现 set_value 方法") + + +class ConfigWidgetWrapper(ConfigWidgetBase): + """ + 配置控件包装器 + + 将现有 Qt 控件包装为 ConfigWidgetBase,用于简化内置配置类型的实现。 + """ + + def __init__( + self, + widget: QWidget, + getter: Callable[[], Any], + setter: Callable[[Any], None], + signal: QObject, + parent: QWidget | None = None, + ): + super().__init__(parent) + self._widget = widget + self._getter = getter + self._setter = setter + + # 将控件添加到布局 + from PyQt5.QtWidgets import QVBoxLayout + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(widget) + + # 连接原始信号到 value_change + signal.connect(self._on_value_changed) + + def _on_value_changed(self) -> None: + """内部信号变化时转发到 value_change""" + self.value_change.emit(self._getter()) + + def get_value(self) -> Any: + return self._getter() + + def set_value(self, value: Any) -> None: + self._setter(value) + + @dataclass class BaseConfig(ABC, Generic[T]): """ @@ -48,15 +109,15 @@ def __post_init__(self) -> None: self.label = "" @abstractmethod - def create_widget(self) -> tuple[QWidget, Callable[[], T], Callable[[T], None], QObject]: + def create_widget(self) -> ConfigWidgetBase: """ 创建 PyQt 控件 Returns: - (控件, 获取值函数, 设置值函数, 值变化信号对象) - - 信号对象应该是一个有 connect 方法的 QObject(如 pyqtSignal)。 - 当值变化时,配置系统会自动连接这个信号来同步值。 + ConfigWidgetBase 实例,必须实现: + - get_value(): 获取当前值 + - set_value(value): 设置当前值 + - value_change 信号: 值变化时发射 """ pass diff --git a/src/plugin_manager/config_types/bool_config.py b/src/plugin_sdk/config_types/bool_config.py similarity index 69% rename from src/plugin_manager/config_types/bool_config.py rename to src/plugin_sdk/config_types/bool_config.py index c1fe18a..75bbd5f 100644 --- a/src/plugin_manager/config_types/bool_config.py +++ b/src/plugin_sdk/config_types/bool_config.py @@ -4,12 +4,11 @@ from __future__ import annotations -from typing import Any, Callable +from typing import Any from PyQt5.QtWidgets import QCheckBox -from PyQt5.QtCore import QObject -from .base_config import BaseConfig +from .base_config import BaseConfig, ConfigWidgetWrapper class BoolConfig(BaseConfig[bool]): @@ -27,13 +26,13 @@ def __post_init__(self) -> None: """确保默认值是布尔类型""" self.default = bool(self.default) - def create_widget(self) -> tuple[QCheckBox, Callable[[], bool], Callable[[bool], None], QObject]: - """创建 QCheckBox 控件,返回 (控件, getter, setter, 信号)""" + def create_widget(self) -> ConfigWidgetWrapper: + """创建 QCheckBox 控件""" widget = QCheckBox() widget.setChecked(self.default) if self.description: widget.setToolTip(self.description) - return widget, widget.isChecked, widget.setChecked, widget.stateChanged + return ConfigWidgetWrapper(widget, widget.isChecked, widget.setChecked, widget.stateChanged) def to_storage(self, value: bool) -> bool: """转换为存储格式""" diff --git a/src/plugin_manager/config_types/choice_config.py b/src/plugin_sdk/config_types/choice_config.py similarity index 82% rename from src/plugin_manager/config_types/choice_config.py rename to src/plugin_sdk/config_types/choice_config.py index 3e76df1..147d851 100644 --- a/src/plugin_manager/config_types/choice_config.py +++ b/src/plugin_sdk/config_types/choice_config.py @@ -5,12 +5,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Callable +from typing import Any -from PyQt5.QtCore import QObject from PyQt5.QtWidgets import QComboBox -from .base_config import BaseConfig +from .base_config import BaseConfig, ConfigWidgetWrapper @dataclass @@ -40,8 +39,8 @@ def __post_init__(self) -> None: """确保默认值是字符串类型""" self.default = str(self.default) - def create_widget(self) -> tuple[QComboBox, Callable[[], str], Callable[[str], None], QObject]: - """创建 QComboBox 控件,返回 (控件, getter, setter, 信号)""" + def create_widget(self) -> ConfigWidgetWrapper: + """创建 QComboBox 控件""" widget = QComboBox() for value, text in self.choices: @@ -64,7 +63,7 @@ def set_value(value: str) -> None: if idx >= 0: widget.setCurrentIndex(idx) - return widget, get_value, set_value, widget.currentIndexChanged + return ConfigWidgetWrapper(widget, get_value, set_value, widget.currentIndexChanged) def to_storage(self, value: str) -> str: """转换为存储格式""" diff --git a/src/plugin_sdk/config_types/color_config.py b/src/plugin_sdk/config_types/color_config.py new file mode 100644 index 0000000..a0b6a68 --- /dev/null +++ b/src/plugin_sdk/config_types/color_config.py @@ -0,0 +1,94 @@ +""" +颜色配置类型 → 颜色选择按钮 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from PyQt5.QtGui import QColor +from PyQt5.QtWidgets import QPushButton, QColorDialog, QHBoxLayout + +from .base_config import BaseConfig, ConfigWidgetBase + + +@dataclass +class ColorConfig(BaseConfig[str]): + """ + 颜色配置 → QPushButton + QColorDialog + + Args: + default: 默认颜色(格式 "#RRGGBB" 或 "#AARRGGBB") + label: 显示标签 + description: tooltip 提示 + + 用法:: + + theme_color = ColorConfig("#1976d2", "主题颜色") + highlight_color = ColorConfig("#ff5722", "高亮颜色") + """ + + widget_type = "color" + + def __post_init__(self) -> None: + """确保默认值是有效的颜色格式""" + if not self.default.startswith("#"): + self.default = "#" + self.default + + def create_widget(self) -> ConfigWidgetBase: + """创建颜色选择按钮""" + + class ColorWidget(ConfigWidgetBase): + def __init__(self, default: str, description: str, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + self._color = default + + # 颜色预览按钮 + self._btn = QPushButton() + self._btn.setFixedSize(40, 24) + self._btn.setStyleSheet(f"background-color: {default}; border: 1px solid #999;") + if description: + self._btn.setToolTip(description) + + # 文本显示 + self._text_btn = QPushButton(default) + self._text_btn.setFixedHeight(24) + self._text_btn.setStyleSheet("text-align: left; padding-left: 4px;") + + self._btn.clicked.connect(self._on_click) + self._text_btn.clicked.connect(self._on_click) + + layout.addWidget(self._btn) + layout.addWidget(self._text_btn, 1) + + def _on_click(self): + color = QColorDialog.getColor(QColor(self._color)) + if color.isValid(): + self.set_value(color.name()) + + def get_value(self) -> str: + return self._color + + def set_value(self, value: str) -> None: + if value and value.startswith("#"): + self._color = value + self._btn.setStyleSheet(f"background-color: {value}; border: 1px solid #999;") + self._text_btn.setText(value) + self.value_change.emit(value) + + return ColorWidget(self.default, self.description) + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + if isinstance(data, str) and data.startswith("#"): + return data + return self.default diff --git a/src/plugin_sdk/config_types/file_config.py b/src/plugin_sdk/config_types/file_config.py new file mode 100644 index 0000000..53769f0 --- /dev/null +++ b/src/plugin_sdk/config_types/file_config.py @@ -0,0 +1,94 @@ +""" +文件配置类型 → 文件选择器 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from PyQt5.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QFileDialog + +from .base_config import BaseConfig, ConfigWidgetBase + + +@dataclass +class FileConfig(BaseConfig[str]): + """ + 文件配置 → QLineEdit + QPushButton (浏览) + + Args: + default: 默认文件路径 + label: 显示标签 + description: tooltip 提示 + filter: 文件过滤器(如 "JSON Files (*.json)") + save_mode: True 表示保存文件对话框,False 表示打开文件对话框 + + 用法:: + + db_file = FileConfig("", "数据库文件", filter="SQLite (*.db)") + export_file = FileConfig("", "导出文件", filter="JSON (*.json)", save_mode=True) + """ + + filter: str = "" + save_mode: bool = False + + widget_type = "file" + + def __post_init__(self) -> None: + """确保默认值是字符串""" + self.default = str(self.default) if self.default else "" + + def create_widget(self) -> ConfigWidgetBase: + """创建文件选择器""" + + class FileWidget(ConfigWidgetBase): + def __init__(self, default: str, description: str, filter: str, save_mode: bool, parent=None): + super().__init__(parent) + self._filter = filter + self._save_mode = save_mode + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + self._line_edit = QLineEdit(default) + if description: + self._line_edit.setToolTip(description) + + btn = QPushButton("浏览") + btn.setFixedWidth(50) + btn.clicked.connect(self._on_browse) + + layout.addWidget(self._line_edit, 1) + layout.addWidget(btn) + + self._line_edit.textChanged.connect(lambda: self.value_change.emit(self.get_value())) + + def _on_browse(self): + if self._save_mode: + path, _ = QFileDialog.getSaveFileName( + self, "选择文件", self._line_edit.text(), self._filter + ) + else: + path, _ = QFileDialog.getOpenFileName( + self, "选择文件", self._line_edit.text(), self._filter + ) + if path: + self._line_edit.setText(path) + + def get_value(self) -> str: + return self._line_edit.text() + + def set_value(self, value: str) -> None: + self._line_edit.setText(value) + + return FileWidget(self.default, self.description, self.filter, self.save_mode) + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + return str(data) if data else self.default diff --git a/src/plugin_manager/config_types/float_config.py b/src/plugin_sdk/config_types/float_config.py similarity index 79% rename from src/plugin_manager/config_types/float_config.py rename to src/plugin_sdk/config_types/float_config.py index 402f122..507259b 100644 --- a/src/plugin_manager/config_types/float_config.py +++ b/src/plugin_sdk/config_types/float_config.py @@ -5,12 +5,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable +from typing import Any -from PyQt5.QtCore import QObject from PyQt5.QtWidgets import QDoubleSpinBox -from .base_config import BaseConfig +from .base_config import BaseConfig, ConfigWidgetWrapper @dataclass @@ -43,10 +42,8 @@ def __post_init__(self) -> None: """确保默认值是浮点类型""" self.default = float(self.default) - def create_widget( - self, - ) -> tuple[QDoubleSpinBox, Callable[[], float], Callable[[float], None], QObject]: - """创建 QDoubleSpinBox 控件,返回 (控件, getter, setter, 信号)""" + def create_widget(self) -> ConfigWidgetWrapper: + """创建 QDoubleSpinBox 控件""" widget = QDoubleSpinBox() widget.setRange(self.min_value, self.max_value) widget.setValue(self.default) @@ -54,7 +51,7 @@ def create_widget( widget.setDecimals(self.decimals) if self.description: widget.setToolTip(self.description) - return widget, widget.value, widget.setValue, widget.valueChanged + return ConfigWidgetWrapper(widget, widget.value, widget.setValue, widget.valueChanged) def to_storage(self, value: float) -> float: """转换为存储格式""" diff --git a/src/plugin_manager/config_types/int_config.py b/src/plugin_sdk/config_types/int_config.py similarity index 79% rename from src/plugin_manager/config_types/int_config.py rename to src/plugin_sdk/config_types/int_config.py index abb2a1b..aa33c57 100644 --- a/src/plugin_manager/config_types/int_config.py +++ b/src/plugin_sdk/config_types/int_config.py @@ -5,12 +5,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable +from typing import Any from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QSpinBox, QSlider -from .base_config import BaseConfig +from .base_config import BaseConfig, ConfigWidgetWrapper @dataclass @@ -44,12 +44,8 @@ def __post_init__(self) -> None: """确保默认值是整数类型""" self.default = int(self.default) - def create_widget( - self, - ) -> tuple[QSpinBox | QSlider, Callable[[], int], Callable[[int], None], QObject]: - """创建 QSpinBox 或 QSlider 控件,返回 (控件, getter, setter, 信号)""" - from PyQt5.QtCore import QObject - + def create_widget(self) -> ConfigWidgetWrapper: + """创建 QSpinBox 或 QSlider 控件""" if self.use_slider: widget = QSlider(Qt.Horizontal) widget.setRange(self.min_value, self.max_value) @@ -57,7 +53,7 @@ def create_widget( widget.setSingleStep(self.step) if self.description: widget.setToolTip(self.description) - return widget, widget.value, widget.setValue, widget.valueChanged + return ConfigWidgetWrapper(widget, widget.value, widget.setValue, widget.valueChanged) else: widget = QSpinBox() widget.setRange(self.min_value, self.max_value) @@ -65,7 +61,7 @@ def create_widget( widget.setSingleStep(self.step) if self.description: widget.setToolTip(self.description) - return widget, widget.value, widget.setValue, widget.valueChanged + return ConfigWidgetWrapper(widget, widget.value, widget.setValue, widget.valueChanged) def to_storage(self, value: int) -> int: """转换为存储格式""" diff --git a/src/plugin_manager/config_types/long_text_config.py b/src/plugin_sdk/config_types/long_text_config.py similarity index 79% rename from src/plugin_manager/config_types/long_text_config.py rename to src/plugin_sdk/config_types/long_text_config.py index 3fe10db..50ac086 100644 --- a/src/plugin_manager/config_types/long_text_config.py +++ b/src/plugin_sdk/config_types/long_text_config.py @@ -5,12 +5,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable +from typing import Any -from PyQt5.QtCore import QObject from PyQt5.QtWidgets import QTextEdit -from .base_config import BaseConfig +from .base_config import BaseConfig, ConfigWidgetWrapper @dataclass @@ -40,8 +39,8 @@ def __post_init__(self) -> None: """确保默认值是字符串""" self.default = str(self.default) if self.default else "" - def create_widget(self) -> tuple[QTextEdit, Callable[[], str], Callable[[str], None], QObject]: - """创建多行文本编辑器,返回 (控件, getter, setter, 信号)""" + def create_widget(self) -> ConfigWidgetWrapper: + """创建多行文本编辑器""" widget = QTextEdit() widget.setPlainText(str(self.default)) widget.setMaximumHeight(self.max_height) @@ -52,7 +51,7 @@ def create_widget(self) -> tuple[QTextEdit, Callable[[], str], Callable[[str], N if self.description: widget.setToolTip(self.description) - return widget, widget.toPlainText, widget.setPlainText, widget.textChanged + return ConfigWidgetWrapper(widget, widget.toPlainText, widget.setPlainText, widget.textChanged) def to_storage(self, value: str) -> str: """转换为存储格式""" diff --git a/src/plugin_manager/config_types/other_info.py b/src/plugin_sdk/config_types/other_info.py similarity index 100% rename from src/plugin_manager/config_types/other_info.py rename to src/plugin_sdk/config_types/other_info.py diff --git a/src/plugin_sdk/config_types/path_config.py b/src/plugin_sdk/config_types/path_config.py new file mode 100644 index 0000000..16cdf38 --- /dev/null +++ b/src/plugin_sdk/config_types/path_config.py @@ -0,0 +1,82 @@ +""" +目录配置类型 → 目录选择器 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from PyQt5.QtWidgets import QHBoxLayout, QLineEdit, QPushButton, QFileDialog + +from .base_config import BaseConfig, ConfigWidgetBase + + +@dataclass +class PathConfig(BaseConfig[str]): + """ + 目录配置 → QLineEdit + QPushButton (浏览) + + Args: + default: 默认目录路径 + label: 显示标签 + description: tooltip 提示 + + 用法:: + + log_dir = PathConfig("", "日志目录") + cache_dir = PathConfig("", "缓存目录") + """ + + widget_type = "path" + + def __post_init__(self) -> None: + """确保默认值是字符串""" + self.default = str(self.default) if self.default else "" + + def create_widget(self) -> ConfigWidgetBase: + """创建目录选择器""" + + class PathWidget(ConfigWidgetBase): + def __init__(self, default: str, description: str, parent=None): + super().__init__(parent) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + self._line_edit = QLineEdit(default) + if description: + self._line_edit.setToolTip(description) + + btn = QPushButton("浏览") + btn.setFixedWidth(50) + btn.clicked.connect(self._on_browse) + + layout.addWidget(self._line_edit, 1) + layout.addWidget(btn) + + self._line_edit.textChanged.connect(lambda: self.value_change.emit(self.get_value())) + + def _on_browse(self): + path = QFileDialog.getExistingDirectory( + self, "选择目录", self._line_edit.text() + ) + if path: + self._line_edit.setText(path) + + def get_value(self) -> str: + return self._line_edit.text() + + def set_value(self, value: str) -> None: + self._line_edit.setText(value) + + return PathWidget(self.default, self.description) + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + return str(data) if data else self.default diff --git a/src/plugin_sdk/config_types/range_config.py b/src/plugin_sdk/config_types/range_config.py new file mode 100644 index 0000000..1a42a38 --- /dev/null +++ b/src/plugin_sdk/config_types/range_config.py @@ -0,0 +1,104 @@ +""" +数值范围配置类型 → 两个 QSpinBox +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from PyQt5.QtWidgets import QHBoxLayout, QSpinBox, QLabel + +from .base_config import BaseConfig, ConfigWidgetBase + + +@dataclass +class RangeConfig(BaseConfig[tuple[int, int]]): + """ + 数值范围配置 → 两个 QSpinBox + + Args: + default: 默认范围值 (min, max) + label: 显示标签 + description: tooltip 提示 + min_value: 最小允许值 + max_value: 最大允许值 + step: 步进值 + + 用法:: + + time_range = RangeConfig((0, 300), "时间范围(秒)", min_value=0, max_value=999) + bbbv_range = RangeConfig((0, 999), "3BV范围", min_value=0, max_value=9999) + """ + + min_value: int = 0 + max_value: int = 9999 + step: int = 1 + + widget_type = "range" + + def __post_init__(self) -> None: + """确保默认值是元组""" + if not isinstance(self.default, tuple): + self.default = (self.min_value, self.max_value) + self.default = (int(self.default[0]), int(self.default[1])) + + def create_widget(self) -> ConfigWidgetBase: + """创建范围选择器""" + + class RangeWidget(ConfigWidgetBase): + def __init__(self, default: tuple[int, int], min_val: int, max_val: int, step: int, description: str, parent=None): + super().__init__(parent) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # 最小值 + self._min_spin = QSpinBox() + self._min_spin.setRange(min_val, max_val) + self._min_spin.setValue(default[0]) + self._min_spin.setSingleStep(step) + + # 分隔符 + sep = QLabel("-") + + # 最大值 + self._max_spin = QSpinBox() + self._max_spin.setRange(min_val, max_val) + self._max_spin.setValue(default[1]) + self._max_spin.setSingleStep(step) + + if description: + self._min_spin.setToolTip(description) + self._max_spin.setToolTip(description) + + layout.addWidget(self._min_spin) + layout.addWidget(sep) + layout.addWidget(self._max_spin) + + self._min_spin.valueChanged.connect(self._on_change) + self._max_spin.valueChanged.connect(self._on_change) + + def _on_change(self): + self.value_change.emit(self.get_value()) + + def get_value(self) -> tuple[int, int]: + return (self._min_spin.value(), self._max_spin.value()) + + def set_value(self, value: tuple[int, int]) -> None: + if isinstance(value, tuple) and len(value) == 2: + self._min_spin.setValue(int(value[0])) + self._max_spin.setValue(int(value[1])) + + return RangeWidget(self.default, self.min_value, self.max_value, self.step, self.description) + + def to_storage(self, value: tuple[int, int]) -> list[int]: + """转换为存储格式(JSON 不支持元组)""" + return [int(value[0]), int(value[1])] + + def from_storage(self, data: Any) -> tuple[int, int]: + """从存储格式恢复""" + if isinstance(data, (list, tuple)) and len(data) == 2: + return (int(data[0]), int(data[1])) + return self.default diff --git a/src/plugin_manager/config_types/text_config.py b/src/plugin_sdk/config_types/text_config.py similarity index 79% rename from src/plugin_manager/config_types/text_config.py rename to src/plugin_sdk/config_types/text_config.py index 2edf781..d893aea 100644 --- a/src/plugin_manager/config_types/text_config.py +++ b/src/plugin_sdk/config_types/text_config.py @@ -5,12 +5,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable +from typing import Any -from PyQt5.QtCore import QObject from PyQt5.QtWidgets import QLineEdit -from .base_config import BaseConfig +from .base_config import BaseConfig, ConfigWidgetWrapper @dataclass @@ -40,8 +39,8 @@ def __post_init__(self) -> None: """确保默认值是字符串类型""" self.default = str(self.default) - def create_widget(self) -> tuple[QLineEdit, Callable[[], str], Callable[[str], None], QObject]: - """创建 QLineEdit 控件,返回 (控件, getter, setter, 信号)""" + def create_widget(self) -> ConfigWidgetWrapper: + """创建 QLineEdit 控件""" widget = QLineEdit() widget.setText(str(self.default)) @@ -54,7 +53,7 @@ def create_widget(self) -> tuple[QLineEdit, Callable[[], str], Callable[[str], N if self.description: widget.setToolTip(self.description) - return widget, widget.text, widget.setText, widget.textChanged + return ConfigWidgetWrapper(widget, widget.text, widget.setText, widget.textChanged) def to_storage(self, value: str) -> str: """转换为存储格式""" diff --git a/src/plugin_sdk/control_auth.py b/src/plugin_sdk/control_auth.py new file mode 100644 index 0000000..841081a --- /dev/null +++ b/src/plugin_sdk/control_auth.py @@ -0,0 +1,299 @@ +""" +控制授权管理器 + +管理插件对控制命令的使用权限: +- 每个控制命令只能授权给一个插件 +- 未授权的控制命令,所有插件都不能发送 +- 授权变更时通知相关插件 +- 持久化授权配置 +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from PyQt5.QtCore import QObject, pyqtSignal + +import loguru + +if TYPE_CHECKING: + from lib_zmq_plugins.shared.base import BaseCommand + +logger = loguru.logger.bind(name="ControlAuth") + + +class ControlAuthorizationManager(QObject): + """ + 控制授权管理器(单例) + + Signals: + authorization_changed(str, str, bool): 授权变更信号 + - 参数: (tag, plugin_name, granted) + - granted=True 表示授权,False 表示撤销 + """ + + authorization_changed = pyqtSignal(str, str, bool) + + _instance: ControlAuthorizationManager | None = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + @classmethod + def instance(cls, config_dir: Path | None = None) -> ControlAuthorizationManager: + """获取单例""" + if cls._instance is None: + cls._instance = cls(config_dir) + return cls._instance + + def __init__(self, config_dir: Path | None = None): + if getattr(self, '_initialized', False): + return + + super().__init__() + + if config_dir is None: + from plugin_manager.app_paths import get_data_dir + config_dir = get_data_dir() + + self._file = config_dir / "control_authorization.json" + self._authorizations: dict[str, str] = {} # {tag: plugin_name} + self._dirty = False + + self.load() + self._initialized = True + + # ── 持久化 ───────────────────────────────────────── + + def load(self) -> None: + """从文件加载授权配置""" + if not self._file.exists(): + logger.debug(f"授权配置文件不存在: {self._file}") + return + + try: + data = json.loads(self._file.read_text(encoding='utf-8')) + if isinstance(data, dict): + self._authorizations = { + k: v for k, v in data.items() + if isinstance(k, str) and isinstance(v, str) + } + logger.info(f"已加载 {len(self._authorizations)} 个控制授权") + except Exception as e: + logger.error(f"加载授权配置失败: {e}") + self._authorizations = {} + + def save(self) -> None: + """保存授权配置到文件""" + if not self._dirty: + return + + try: + self._file.parent.mkdir(parents=True, exist_ok=True) + self._file.write_text( + json.dumps(self._authorizations, indent=2, ensure_ascii=False), + encoding='utf-8' + ) + self._dirty = False + logger.debug(f"授权配置已保存: {self._file}") + except Exception as e: + logger.error(f"保存授权配置失败: {e}") + + # ── 核心方法 ───────────────────────────────────────── + + def _get_tag(self, command_type: type) -> str: + """从命令类型获取 tag""" + tag = getattr(command_type, '__struct_config__', None) + if tag is not None: + tag = getattr(tag, 'tag', None) + if tag is None: + raise ValueError(f"无法获取命令类型的 tag: {command_type}") + return str(tag) + + def authorize( + self, + command_type: type, + plugin_name: str, + ) -> None: + """ + 授权控制类型给指定插件 + + Args: + command_type: 命令类型 + plugin_name: 插件名称 + """ + tag = self._get_tag(command_type) + old_plugin = self._authorizations.get(tag) + + # 先通知旧插件被撤销 + if old_plugin is not None and old_plugin != plugin_name: + logger.info(f"控制授权撤销: {tag} (原: {old_plugin})") + self.authorization_changed.emit(tag, old_plugin, False) + + self._authorizations[tag] = plugin_name + self._dirty = True + + logger.info(f"控制授权: {tag} -> {plugin_name}") + + # 通知新插件被授权 + self.authorization_changed.emit(tag, plugin_name, True) + + def revoke( + self, + command_type: type, + ) -> str | None: + """ + 撤销授权 + + Args: + command_type: 命令类型 + + Returns: + 被撤销授权的插件名称,如果原本就没有授权则返回 None + """ + tag = self._get_tag(command_type) + old_plugin = self._authorizations.pop(tag, None) + + if old_plugin is not None: + self._dirty = True + logger.info(f"控制授权撤销: {tag} (原: {old_plugin})") + self.authorization_changed.emit(tag, old_plugin, False) + + return old_plugin + + def is_authorized( + self, + command_type: type, + plugin_name: str, + ) -> bool: + """ + 检查插件是否有授权 + + Args: + command_type: 命令类型 + plugin_name: 插件名称 + + Returns: + True 表示已授权 + """ + tag = self._get_tag(command_type) + return self._authorizations.get(tag) == plugin_name + + def has_control_auth( + self, + command_type: type, + ) -> bool: + """ + 检查该控制类型是否已授权给某个插件 + + Args: + command_type: 命令类型 + + Returns: + True 表示已授权给某个插件 + """ + tag = self._get_tag(command_type) + return tag in self._authorizations + + def get_authorized_plugin( + self, + command_type: type, + ) -> str | None: + """ + 获取该控制类型授权给的插件 + + Args: + command_type: 命令类型 + + Returns: + 插件名称,未授权则返回 None + """ + tag = self._get_tag(command_type) + return self._authorizations.get(tag) + + # ── 批量操作 ───────────────────────────────────────── + + def get_all_control_types(self) -> list[type]: + """获取所有控制类型""" + from shared_types.commands import COMMAND_TYPES + return list(COMMAND_TYPES) + + def get_authorization_status(self) -> dict[str, str | None]: + """ + 获取所有控制类型的授权状态 + + Returns: + {tag: plugin_name | None} + """ + result = {} + for cmd_type in self.get_all_control_types(): + try: + tag = self._get_tag(cmd_type) + result[tag] = self._authorizations.get(tag) + except ValueError: + continue + return result + + def validate_authorizations( + self, + loaded_plugins: set[str], + ) -> list[tuple[str, str]]: + """ + 验证授权配置,清除无效插件的授权 + + Args: + loaded_plugins: 已加载的插件名称集合 + + Returns: + 被清除的授权列表 [(tag, plugin_name), ...] + """ + removed = [] + to_remove = [] + + for tag, plugin_name in self._authorizations.items(): + if plugin_name not in loaded_plugins: + to_remove.append(tag) + removed.append((tag, plugin_name)) + + for tag in to_remove: + del self._authorizations[tag] + self._dirty = True + + if removed: + logger.warning( + f"清除无效授权: {removed} (插件未加载)" + ) + + return removed + + def clear_all(self) -> None: + """清除所有授权""" + for tag, plugin_name in list(self._authorizations.items()): + self.authorization_changed.emit(tag, plugin_name, False) + + self._authorizations.clear() + self._dirty = True + logger.info("已清除所有控制授权") + + # ── 批量设置 ───────────────────────────────────────── + + def set_authorizations(self, authorizations: dict[type, str | None]) -> None: + """ + 批量设置授权 + + Args: + authorizations: {command_type: plugin_name | None} + - plugin_name 为 None 表示撤销授权 + """ + for command_type, plugin_name in authorizations.items(): + try: + if plugin_name is None: + self.revoke(command_type) + else: + self.authorize(command_type, plugin_name) + except ValueError as e: + logger.error(f"设置授权失败: {e}") diff --git a/src/plugin_manager/plugin_base.py b/src/plugin_sdk/plugin_base.py similarity index 84% rename from src/plugin_manager/plugin_base.py rename to src/plugin_sdk/plugin_base.py index 226acc0..905b72d 100644 --- a/src/plugin_manager/plugin_base.py +++ b/src/plugin_sdk/plugin_base.py @@ -10,13 +10,17 @@ """ from __future__ import annotations +from .service_registry import ServiceNotFoundError +from lib_zmq_plugins.shared.base import BaseEvent, get_event_tag +from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPen, QColor, QBrush, QFont +from PyQt5.QtCore import Qt, QThread, QObject, pyqtSignal, pyqtSlot from collections import deque from concurrent.futures import Future import threading from abc import abstractmethod from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from typing import TYPE_CHECKING, Any, Callable, ClassVar, TypeVar, cast @@ -29,17 +33,11 @@ if TYPE_CHECKING: from PyQt5.QtGui import QIcon -from PyQt5.QtCore import Qt, QThread, QObject, pyqtSignal, pyqtSlot -from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPen, QColor, QBrush, QFont - -from lib_zmq_plugins.shared.base import BaseEvent, get_event_tag - -from .service_registry import ServiceNotFoundError if TYPE_CHECKING: from PyQt5.QtWidgets import QWidget from lib_zmq_plugins.client.zmq_client import ZMQClient - from .event_dispatcher import EventDispatcher + from plugin_manager.event_dispatcher import EventDispatcher def make_plugin_icon( @@ -79,7 +77,8 @@ def make_plugin_icon( p.setBrush(Qt.NoBrush) # type: ignore[attr-defined] font = QFont("Segoe UI Emoji", int(size * 0.44), QFont.Bold) p.setFont(font) - p.drawText(pix.rect(), Qt.AlignCenter | Qt.AlignVCenter, symbol) # type: ignore[attr-defined] + p.drawText(pix.rect(), Qt.AlignCenter | Qt.AlignVCenter, + symbol) # type: ignore[attr-defined] p.end() return QIcon(pix) @@ -106,15 +105,15 @@ def _values(cls) -> list[str]: class _ServiceProxy: """ 服务代理对象(内部使用) - + 拦截属性访问,将方法调用转换为 call_service 调用。 让插件开发者可以直接通过属性访问方式调用服务方法。 """ - + def __init__(self, plugin: "BasePlugin", protocol: type): object.__setattr__(self, "_plugin", plugin) object.__setattr__(self, "_protocol", protocol) - + def __getattr__(self, name: str) -> Any: # 返回一个可调用对象,调用时转发到 _call_service def _method_call(*args, timeout: float = 10.0, **kwargs) -> Any: @@ -128,7 +127,7 @@ def _method_call(*args, timeout: float = 10.0, **kwargs) -> Any: self._protocol, name, *args, timeout=timeout ) return _method_call - + def __repr__(self) -> str: return f"" @@ -138,7 +137,7 @@ class PluginLifecycle(str, Enum): NEW = "NEW" # 刚创建,未初始化 INITIALIZING = "INITIALIZING" # 线程已启动,on_initialized() 正在执行 READY = "READY" # on_initialized() 完成,正常运行 - SHUTTING_DOWN = "SHUTTING_DOWN" # shutdown() 调用中 + SHUTTING_DOWN = "SHUTTING_DOWN" # shutdown() 调用中 STOPPED = "STOPPED" # 已停止 @@ -181,6 +180,8 @@ class PluginInfo: log_config: "LogConfig | None" = None # 日志轮转配置,None 使用全局默认值 # 插件自定义配置类(继承自 OtherInfoBase) other_info: type["OtherInfoBase"] | None = None + # 声明需要的控制权限(命令类型列表) + required_controls: list[type] = field(default_factory=list) class BasePlugin(QThread): @@ -247,16 +248,17 @@ def __init__(self, info: PluginInfo): self._resource_lock = threading.RLock() # 保护内部共享状态 # 连接 gui_call 信号到槽(QueuedConnection 跨线程安全) - self.gui_call.connect(self._on_gui_call, Qt.ConnectionType.QueuedConnection) + self.gui_call.connect( + self._on_gui_call, Qt.ConnectionType.QueuedConnection) # 每个插件拥有独立的 loguru logger(日志写入 plugins/.log) - from .logging_setup import get_plugin_logger + from plugin_manager.logging_setup import get_plugin_logger self.logger, self._log_sink_id = get_plugin_logger( info.name, log_config=info.log_config, ) self._log_level: LogLevel = info.log_level - + # 记录本插件注册的服务(用于 shutdown 时自动注销) self._registered_protocols: list[type] = [] @@ -264,8 +266,8 @@ def __init__(self, info: PluginInfo): self._other_info: OtherInfoBase | None = None self._config_manager: PluginConfigManager | None = None if info.other_info is not None: - from .config_manager import PluginConfigManager - from .app_paths import get_plugin_data_dir + from plugin_manager.config_manager import PluginConfigManager + from plugin_manager.app_paths import get_plugin_data_dir # 实例化配置对象 self._other_info = info.other_info() @@ -324,7 +326,7 @@ def client(self) -> ZMQClient | None: def data_dir(self) -> "Path": """插件专属数据目录(可写),自动根据插件类名创建""" from pathlib import Path - from .app_paths import get_plugin_data_dir + from plugin_manager.app_paths import get_plugin_data_dir if not hasattr(self, "_data_dir"): self._data_dir = get_plugin_data_dir(type(self)) @@ -348,7 +350,7 @@ def save_config(self) -> None: def set_log_level(self, level: LogLevel | str) -> None: """动态设置插件的日志级别""" - from .logging_setup import set_plugin_log_level + from plugin_manager.logging_setup import set_plugin_log_level if isinstance(level, str): level = LogLevel(level.upper()) self._log_level = level @@ -502,11 +504,40 @@ def initialize(self) -> None: self._widget = self._create_widget() self._lifecycle = PluginLifecycle.INITIALIZING + # 连接控制授权变更信号 + self._connect_control_auth_signal() + # 启动插件的事件处理线程(on_initialized 在 run 中执行) self._stop_requested.clear() self.start() self.logger.debug(f"Plugin thread launched: {self.name}") + def _connect_control_auth_signal(self) -> None: + """连接控制授权变更信号""" + from .control_auth import ControlAuthorizationManager + + auth_manager = ControlAuthorizationManager.instance() + + def on_auth_changed(tag: str, plugin_name: str, granted: bool) -> None: + # 只处理与当前插件相关的授权变更 + if plugin_name != self.name: + return + + # 查找对应的命令类型 + for cmd_type in auth_manager.get_all_control_types(): + try: + cmd_tag = auth_manager._get_tag(cmd_type) + if cmd_tag == tag: + # 在主线程调用回调 + self.run_on_gui( + self.on_control_auth_changed, cmd_type, granted + ) + break + except ValueError: + continue + + auth_manager.authorization_changed.connect(on_auth_changed) + def shutdown(self) -> None: """关闭插件并停止事件处理线程""" if self._lifecycle == PluginLifecycle.STOPPED: @@ -522,21 +553,24 @@ def shutdown(self) -> None: # 等待线程结束(最多 2 秒) # on_shutdown 已在 run() 末尾的插件线程中执行 if not self.wait(2000): - self.logger.warning(f"Plugin thread did not stop in time: {self.name}") + self.logger.warning( + f"Plugin thread did not stop in time: {self.name}") self.terminate() # 强制终止 # 注意:强制终止可能导致未完成的 Future 永久阻塞 # 调用方应设置超时并处理 TimeoutError 异常 if self._event_dispatcher: self._event_dispatcher.unsubscribe_all(self) - + # 注销本插件注册的所有服务 for protocol in self._registered_protocols: try: self._event_dispatcher.services.unregister(protocol) - self.logger.debug(f"Unregistered service: {protocol.__name__}") + self.logger.debug( + f"Unregistered service: {protocol.__name__}") except Exception as e: - self.logger.warning(f"Failed to unregister service {protocol.__name__}: {e}") + self.logger.warning( + f"Failed to unregister service {protocol.__name__}: {e}") self._registered_protocols.clear() if self._widget: @@ -608,6 +642,23 @@ def on_shutdown(self) -> None: """插件关闭前回调""" pass + def on_control_auth_changed( + self, + command_type: type, + granted: bool, + ) -> None: + """ + 控制权限变更回调 + + 当插件获得或失去某个控制命令的权限时调用。 + 子类可以覆写此方法以响应权限变化。 + + Args: + command_type: 命令类型 + granted: True 表示获得权限,False 表示失去权限 + """ + pass + # ═══════════════════════════════════════════════════════════════════ # 事件订阅(使用事件类) # ═══════════════════════════════════════════════════════════════════ @@ -620,7 +671,8 @@ def subscribe( """订阅事件""" if self._event_dispatcher: tag = get_event_tag(event_class) - self._event_dispatcher.subscribe(tag, handler, self._info.priority, self) + self._event_dispatcher.subscribe( + tag, handler, self._info.priority, self) def unsubscribe(self, event_class: type[BaseEvent]) -> None: """取消订阅事件""" @@ -632,13 +684,68 @@ def unsubscribe(self, event_class: type[BaseEvent]) -> None: # 指令发送 # ═══════════════════════════════════════════════════════════════════ + def has_control_auth(self, command_type: type) -> bool: + """ + 检查当前插件是否有该控制类型的权限 + + Args: + command_type: 命令类型 + + Returns: + True 表示有权限 + """ + from .control_auth import ControlAuthorizationManager + auth_manager = ControlAuthorizationManager.instance() + return auth_manager.is_authorized(command_type, self.name) + + def _check_control_auth(self, command: Any) -> bool: + """检查控制权限""" + from .control_auth import ControlAuthorizationManager + + # 获取命令的 tag + config = getattr(command, '__struct_config__', None) + if config is None: + self.logger.debug(f"命令无 struct_config,允许发送: {type(command)}") + return True # 非结构化命令,允许发送 + + tag = getattr(config, 'tag', None) + if tag is None: + self.logger.debug(f"命令无 tag,允许发送: {type(command)}") + return True + + auth_manager = ControlAuthorizationManager.instance() + + # 检查授权状态 + authorized_plugin = auth_manager.get_authorized_plugin(type(command)) + self.logger.debug( + f"控制权限检查: tag={tag}, 授权给={authorized_plugin}, 当前插件={self.name}" + ) + + if not auth_manager.is_authorized(type(command), self.name): + self.logger.warning( + f"控制权限被拒绝: {tag} 未授权给 {self.name} (当前授权给: {authorized_plugin})" + ) + return False + return True + def send_command(self, command: Any) -> None: - """发送控制指令到主进程(异步)""" + """发送控制指令到主进程(异步,带权限检查)""" + if not self._check_control_auth(command): + return if self._client: - self._client.send_command(command) + try: + self.logger.info(f"发送命令到 ZMQ: {type(command).__name__}") + self._client.send_command(command) + self.logger.info(f"命令已发送: {type(command).__name__}") + except Exception as e: + self.logger.error(f"发送命令失败: {e}", exc_info=True) + else: + self.logger.warning(f"无法发送命令: client 未初始化") - def request(self, command: Any, timeout: float = 5.0) -> Any: - """发送请求并等待响应(同步)""" + def request(self, command: Any, timeout: float = 5.0) -> CommandResponse | None: + """发送请求并等待响应(同步,带权限检查)""" + if not self._check_control_auth(command): + return None if self._client: return self._client.request(command, timeout) return None @@ -655,16 +762,16 @@ def register_service( ) -> None: """ 注册服务(供其他插件调用) - + Args: provider: 服务提供者实例(通常是 self) protocol: 服务接口类型(可选,自动推断) - + Raises: TypeError: 未实现 Protocol 的所有方法 - + 用法:: - + class MyPlugin(BasePlugin): def on_initialized(self): # 显式指定 protocol @@ -673,7 +780,7 @@ def on_initialized(self): if self._event_dispatcher is None: self.logger.warning("Cannot register service: no dispatcher") return - + # 自动推断 protocol if protocol is None: # 从 provider 的基类中找到 Protocol 子类 @@ -685,25 +792,26 @@ def on_initialized(self): ): protocol = base break - + if protocol is None: self.logger.warning( "Cannot register service: no protocol found. " "Specify protocol= explicitly or inherit from a Protocol." ) return - + # 验证 provider 实现了 Protocol 的所有方法 - missing_methods = self._check_protocol_implementation(provider, protocol) + missing_methods = self._check_protocol_implementation( + provider, protocol) if missing_methods: raise TypeError( f"Cannot register service: {type(provider).__name__} does not implement " f"{protocol.__name__}. Missing methods: {', '.join(missing_methods)}" ) - + # 注册服务 self._event_dispatcher.services.register(protocol, provider, self.name) - + # 记录已注册的 protocol(用于 shutdown 时自动注销) if protocol not in self._registered_protocols: self._registered_protocols.append(protocol) @@ -715,69 +823,99 @@ def _check_protocol_implementation( ) -> list[str]: """ 检查 provider 是否实现了 Protocol 的所有方法 - + Returns: 缺失的方法名列表(空列表表示全部实现) """ missing = [] - + # 获取 Protocol 中定义的所有方法(不包括继承自 object 的) for name in dir(protocol): if name.startswith('_'): continue - + attr = getattr(protocol, name, None) if attr is None: continue - + # 检查是否是方法(Callable) if callable(attr) or isinstance(attr, property): # 检查 provider 是否有该方法 provider_attr = getattr(type(provider), name, None) if provider_attr is None: missing.append(name) - + return missing def _get_service(self, protocol: type[_T]) -> _T: """ 获取服务实例(内部使用) - + 注意:直接调用服务方法会在调用方线程执行,可能有线程安全问题。 推荐使用 get_service_proxy() 获取代理对象。 """ if self._event_dispatcher is None: raise ServiceNotFoundError(protocol) - + return self._event_dispatcher.services.get(protocol) def _try_get_service(self, protocol: type[_T]) -> _T | None: """ 尝试获取服务实例(内部使用) - + 注意:直接调用服务方法会在调用方线程执行,可能有线程安全问题。 推荐使用 get_service_proxy() 获取代理对象。 """ if self._event_dispatcher is None: return None - + return self._event_dispatcher.services.try_get(protocol) def has_service(self, protocol: type) -> bool: """ 检查服务是否可用 - + Args: protocol: 服务接口类型 - + Returns: True 表示服务已注册 """ if self._event_dispatcher is None: return False - + return self._event_dispatcher.services.has(protocol) + def wait_for_service( + self, + protocol: type[_T], + timeout: float = 10.0, + ) -> _T | None: + """ + 等待服务注册完成并获取实例 + + Args: + protocol: 服务接口类型 + timeout: 最大等待时间(秒),默认 10 秒 + + Returns: + 服务实例或 None(超时未注册) + + 用法:: + + def on_initialized(self): + # 等待 HistoryService 就绪 + history = self.wait_for_service(HistoryService, timeout=10.0) + if history is None: + self.logger.warning("HistoryService 未就绪") + else: + records = history.query_records(100) + """ + if self._event_dispatcher is None: + return None + + return self._event_dispatcher.services.wait_for(protocol, timeout) + def _call_service( self, protocol: type, @@ -787,13 +925,13 @@ def _call_service( ) -> Any: """ 调用服务方法(内部实现) - + 由 _ServiceProxy 调用,插件开发者应使用 get_service_proxy()。 - + WARNING - 死锁风险: 如果两个插件互相调用对方的服务(A 调 B 的同时 B 调 A), 会产生死锁,因为双方队列都在等待对方响应。 - + 避免方法: - 使用 call_service_async() 异步调用 - 设计单向依赖关系,避免循环调用 @@ -801,12 +939,12 @@ def _call_service( """ if self._event_dispatcher is None: raise ServiceNotFoundError(protocol) - + provider = self._event_dispatcher.services.get(protocol) - + # 创建 Future 用于接收结果 future: Future[Any] = Future() - + # 定义在服务提供者线程执行的函数 def _execute_in_provider_thread(_: Any) -> None: try: @@ -814,7 +952,7 @@ def _execute_in_provider_thread(_: Any) -> None: future.set_result(result) except Exception as e: future.set_exception(e) - + # 投递到服务提供者的队列 success = provider._enqueue_event(_execute_in_provider_thread, None) if not success: @@ -822,7 +960,7 @@ def _execute_in_provider_thread(_: Any) -> None: f"Failed to enqueue service call: {protocol.__name__}.{method} " "(provider queue full)" ) - + # 等待结果 return future.result(timeout=timeout) @@ -834,17 +972,17 @@ def call_service_async( ) -> Future[Any]: """ 异步调用服务方法(非阻塞,返回 Future) - + Args: protocol: 服务接口类型 method: 方法名 *args: 方法参数 - + Returns: Future 对象,可调用 result() 获取结果 - + 用法:: - + future = self.call_service_async(MyService, "some_method", arg1) # 做其他事情... result = future.result(timeout=5.0) # 阻塞等待结果 @@ -853,54 +991,54 @@ def call_service_async( future: Future[Any] = Future() future.set_exception(ServiceNotFoundError(protocol)) return future - + try: provider = self._event_dispatcher.services.get(protocol) except ServiceNotFoundError as e: future = Future() future.set_exception(e) return future - + future: Future[Any] = Future() - + def _execute_in_provider_thread(_: Any) -> None: try: result = getattr(provider, method)(*args) future.set_result(result) except Exception as e: future.set_exception(e) - + success = provider._enqueue_event(_execute_in_provider_thread, None) if not success: future.set_exception(RuntimeError( f"Failed to enqueue service call: {protocol.__name__}.{method} " "(provider queue full)" )) - + return future def get_service_proxy(self, protocol: type[_T]) -> _T: """ 获取服务代理对象(类型安全,IDE 友好) - + 返回一个代理对象,通过属性访问方式调用服务方法。 所有方法调用都会在服务提供者线程执行,线程安全。 - + Args: protocol: 服务接口类型 - + Returns: 服务代理对象(IDE 可推断类型,支持方法补全) - + 用法:: - + # 获取代理对象 service = self.get_service_proxy(MyService) - + # 直接调用方法(IDE 完整补全) result = service.some_method(arg1, arg2) count = service.get_count() - + # 以上调用等同于: # result = self.call_service(MyService, "some_method", arg1, arg2) # count = self.call_service(MyService, "get_count") diff --git a/src/plugin_sdk/server_bridge.py b/src/plugin_sdk/server_bridge.py new file mode 100644 index 0000000..720ead3 --- /dev/null +++ b/src/plugin_sdk/server_bridge.py @@ -0,0 +1,205 @@ +""" +ZMQ Server 集成模块 + +提供将 ZMQ Server 集成到扫雷主进程的便捷方法 + +使用方式:: + + # 初始化 + bridge = GameServerBridge.instance() + + # 注册指令处理器(在 start 之前) + bridge.register_handler(NewGameCommand, my_handler) + + # 启动服务 + bridge.start() + + # 发送事件 + bridge.send_event(event) +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, TypeVar +import threading +from concurrent.futures import Future + +from PyQt5.QtCore import QObject, pyqtSignal, Qt + +from lib_zmq_plugins.server.zmq_server import ZMQServer +from lib_zmq_plugins.shared.base import BaseEvent, BaseCommand, CommandResponse + +from shared_types import EVENT_TYPES, COMMAND_TYPES + +if TYPE_CHECKING: + from lib_zmq_plugins.log import LogHandler + +import loguru +logger = loguru.logger.bind(name="ServerBridge") + +# 泛型:指令类型 +_C = TypeVar("_C", bound=BaseCommand) +_E = TypeVar("_E", bound=BaseEvent) + + +class GameServerBridge(QObject): + """ + 游戏服务端桥接器(全局单例) + + 只负责 ZMQ 通信层封装,不绑定任何业务逻辑。 + 指令处理器由外部注册。 + + 处理器自动在主线程中执行(通过信号槽机制)。 + """ + + # 内部信号:用于调度到主线程 + _execute_signal = pyqtSignal(object, object, object) # (handler, cmd, future_or_none) + + _instance: GameServerBridge | None = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = QObject.__new__(cls) + cls._instance._initialized = False + return cls._instance + + @classmethod + def instance( + cls, + endpoint: str | None = None, + log_handler: LogHandler | None = None, + ) -> GameServerBridge: + """ + 获取全局单例 + + Args: + endpoint: ZMQ端点地址 + log_handler: 日志处理器 + + Returns: + GameServerBridge 实例 + """ + if cls._instance is None: + cls._instance = cls(endpoint, log_handler) + return cls._instance + + def __init__( + self, + endpoint: str | None = None, + log_handler: LogHandler | None = None, + ): + # 防止重复初始化 + if getattr(self, '_initialized', False): + return + + super().__init__() + + # 默认端点 + if endpoint is None: + endpoint = "tcp://127.0.0.1:5555" + + self._endpoint = endpoint + self._server = ZMQServer(endpoint=endpoint, log_handler=log_handler) + + # 保存主线程引用 + self._main_thread = threading.main_thread() + + # 保存处理器 + self._handlers: dict[str, Callable] = {} + + # 连接内部信号 + self._execute_signal.connect(self._on_execute, Qt.QueuedConnection) + + # 注册类型 + self._server.register_event_types(*EVENT_TYPES) + self._server.register_command_types(*COMMAND_TYPES) + + self._initialized = True + + @property + def endpoint(self) -> str: + return self._endpoint + + @property + def server(self) -> ZMQServer: + """获取底层 ZMQ Server 实例""" + return self._server + + def _on_execute( + self, + handler: Callable, + cmd: BaseCommand, + future: Future | None, + ) -> None: + """在主线程执行处理器""" + try: + result = handler(cmd) + if future is not None: + future.set_result(result) + except Exception as e: + logger.error(f"Handler error: {e}", exc_info=True) + if future is not None: + future.set_exception(e) + + def register_handler( + self, + command_type: type[_C], + handler: Callable[[_C], CommandResponse], + ) -> None: + """ + 注册指令处理器 + + 处理器会自动在主线程中执行。 + + Args: + command_type: 指令类型 + handler: 处理函数,接收指令,返回响应 + + Usage:: + + def handle_new_game(cmd: NewGameCommand) -> CommandResponse: + # 处理逻辑(cmd 类型被正确推断) + return CommandResponse(request_id=cmd.request_id, success=True) + + bridge.register_handler(NewGameCommand, handle_new_game) + """ + # 获取 tag + tag = command_type.__struct_config__.tag + str_tag = str(tag) + logger.info(f"注册处理器: tag={tag}, command_type={command_type.__name__}") + + # 保存 handler + self._handlers[str_tag] = handler + + # 注册到 server + def wrapped_handler(cmd: _C) -> CommandResponse | None: + if cmd.request_id: + # 同步请求:需要等待结果 + future: Future[CommandResponse] = Future() + self._execute_signal.emit(handler, cmd, future) + return future.result(timeout=5.0) + else: + # 异步命令:不等待结果 + self._execute_signal.emit(handler, cmd, None) + return None + + self._server.register_handler(command_type, wrapped_handler) + logger.info(f"处理器已注册: tag={tag}") + + def start(self) -> None: + """启动服务""" + self._server.start() + logger.info(f"Game server bridge started at {self._endpoint}") + + def stop(self) -> None: + """停止服务""" + self._server.stop() + logger.info("Game server bridge stopped") + + def send_event(self, event: BaseEvent) -> None: + """ + 发送事件到客户端 + + Args: + event: 事件对象 + """ + self._server.publish(event.__class__, event) diff --git a/src/plugin_manager/service_registry.py b/src/plugin_sdk/service_registry.py similarity index 72% rename from src/plugin_manager/service_registry.py rename to src/plugin_sdk/service_registry.py index f9ddb70..72921fb 100644 --- a/src/plugin_manager/service_registry.py +++ b/src/plugin_sdk/service_registry.py @@ -23,12 +23,23 @@ class StatsPlugin(BasePlugin): def _update(self): history = self.get_service(HistoryService) records = history.query_records(100) # IDE 完整补全 + +4. 等待服务就绪: + class StatsPlugin(BasePlugin): + def on_initialized(self): + # 等待 HistoryService 就绪,最多等待 10 秒 + history = self.wait_for_service(HistoryService, timeout=10.0) + if history is None: + self.logger.warning("HistoryService 未就绪") + else: + records = history.query_records(100) """ from __future__ import annotations import threading +import time from typing import TypeVar, runtime_checkable, Protocol -from dataclasses import dataclass +from dataclasses import dataclass, field import loguru @@ -67,6 +78,7 @@ class ServiceRegistry: - 类型安全:通过 Protocol 类型获取服务 - IDE 友好:返回类型可推断 - 线程安全:使用 RLock 保护 + - 等待机制:支持等待服务注册完成 Usage:: @@ -78,11 +90,16 @@ class ServiceRegistry: # 获取服务(类型安全) history = registry.get(HistoryService) records = history.query_records(100) # IDE 完整补全 + + # 等待服务就绪 + history = registry.wait_for(HistoryService, timeout=10.0) """ def __init__(self): self._providers: dict[type, ServiceEntry] = {} self._lock = threading.RLock() + # 用于等待服务的条件变量 + self._condition = threading.Condition(self._lock) def register( self, @@ -101,7 +118,7 @@ def register( Raises: ServiceAlreadyRegisteredError: 服务已注册 """ - with self._lock: + with self._condition: if protocol in self._providers: raise ServiceAlreadyRegisteredError(protocol) @@ -110,6 +127,8 @@ def register( provider=provider, plugin_name=plugin_name, ) + # 通知所有等待该服务的线程 + self._condition.notify_all() logger.debug( f"Service registered: {protocol.__name__} " f"(provider: {plugin_name or 'unknown'})" @@ -125,7 +144,7 @@ def unregister(self, protocol: type) -> bool: Returns: True 表示成功注销,False 表示服务不存在 """ - with self._lock: + with self._condition: if protocol in self._providers: entry = self._providers.pop(protocol) logger.debug( @@ -158,7 +177,7 @@ def get(self, protocol: type[_T]) -> _T: history = registry.get(HistoryService) records = history.query_records(100) # IDE 完整补全 """ - with self._lock: + with self._condition: entry = self._providers.get(protocol) if entry is None: raise ServiceNotFoundError(protocol) @@ -174,12 +193,51 @@ def try_get(self, protocol: type[_T]) -> _T | None: Returns: 服务实例或 None """ - with self._lock: + with self._condition: entry = self._providers.get(protocol) if entry is None: return None return entry.provider # type: ignore[return-value] + def wait_for( + self, + protocol: type[_T], + timeout: float = 10.0, + poll_interval: float = 0.1, + ) -> _T | None: + """ + 等待服务注册完成并获取实例 + + Args: + protocol: 服务接口类型 + timeout: 最大等待时间(秒),默认 10 秒 + poll_interval: 轮询间隔(秒),默认 0.1 秒 + + Returns: + 服务实例或 None(超时未注册) + + Usage:: + + # 等待服务就绪 + history = registry.wait_for(HistoryService, timeout=10.0) + if history: + records = history.query_records(100) + """ + deadline = time.monotonic() + timeout + + with self._condition: + while True: + entry = self._providers.get(protocol) + if entry is not None: + return entry.provider # type: ignore[return-value] + + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + + # 等待服务注册通知,最多等待剩余时间 + self._condition.wait(min(remaining, poll_interval)) + def has(self, protocol: type) -> bool: """ 检查服务是否已注册 @@ -190,7 +248,7 @@ def has(self, protocol: type) -> bool: Returns: True 表示已注册 """ - with self._lock: + with self._condition: return protocol in self._providers def list_services(self) -> list[tuple[type, str]]: @@ -200,7 +258,7 @@ def list_services(self) -> list[tuple[type, str]]: Returns: [(protocol, plugin_name), ...] """ - with self._lock: + with self._condition: return [ (entry.protocol, entry.plugin_name) for entry in self._providers.values() @@ -208,7 +266,7 @@ def list_services(self) -> list[tuple[type, str]]: def clear(self) -> None: """清除所有服务注册""" - with self._lock: + with self._condition: self._providers.clear() logger.debug("All services unregistered") diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py index 09705a2..cb30a79 100644 --- a/src/plugins/__init__.py +++ b/src/plugins/__init__.py @@ -1,5 +1,5 @@ """ -示例插件目录 +插件包 -将插件放在此目录下,插件管理器会自动发现和加载。 -""" +此目录包含所有插件模块和服务定义。 +""" \ No newline at end of file diff --git a/src/plugins/hello_world.py b/src/plugins/hello_world.py index 0ff7cea..eebeeaf 100644 --- a/src/plugins/hello_world.py +++ b/src/plugins/hello_world.py @@ -10,8 +10,11 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit, QDial from PyQt5.QtCore import pyqtSignal, Qt -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode -from plugin_manager.config_types import ( +from plugin_sdk import ( + BasePlugin, + PluginInfo, + make_plugin_icon, + WindowMode, OtherInfoBase, BoolConfig, IntConfig, @@ -24,6 +27,8 @@ LongTextConfig, RangeConfig, BaseConfig, + ConfigWidgetBase, + ConfigWidgetWrapper, ) from shared_types.events import VideoSaveEvent @@ -55,10 +60,8 @@ def __init__( self.max_value = max_value self.notch_step = notch_step - def create_widget(self): - """创建 QDial 控件,返回 (控件, getter, setter, 信号)""" - from PyQt5.QtCore import QObject - + def create_widget(self) -> ConfigWidgetBase: + """创建 QDial 控件""" widget = QDial() widget.setRange(self.min_value, self.max_value) widget.setValue(int(self.default)) @@ -71,8 +74,7 @@ def create_widget(self): if self.description: widget.setToolTip(self.description) - # 返回控件、getter、setter、以及 valueChanged 信号 - return widget, widget.value, widget.setValue, widget.valueChanged + return ConfigWidgetWrapper(widget, widget.value, widget.setValue, widget.valueChanged) def to_storage(self, value: int) -> int: return int(value) diff --git a/src/plugins/history/__init__.py b/src/plugins/history/__init__.py new file mode 100644 index 0000000..9bea619 --- /dev/null +++ b/src/plugins/history/__init__.py @@ -0,0 +1,12 @@ +""" +历史记录插件 + +功能: +- 监听 VideoSaveEvent,将游戏录像数据持久化到 SQLite 数据库 +- 提供 GUI 界面:表格浏览、筛选、分页、播放/导出录像 +- 使用 self.data_dir 存储数据库文件(每个插件独立目录) +""" + +from .plugin import HistoryPlugin + +__all__ = ["HistoryPlugin"] diff --git a/src/plugins/history/models.py b/src/plugins/history/models.py new file mode 100644 index 0000000..0f44e2e --- /dev/null +++ b/src/plugins/history/models.py @@ -0,0 +1,200 @@ +""" +历史记录数据模型 +""" + +from __future__ import annotations + +import inspect +from datetime import datetime +from typing import Any + +from PyQt5.QtCore import QCoreApplication + +_translate = QCoreApplication.translate + + +class LogicSymbol: + """逻辑连接符(与/或)""" + + And = 0 + Or = 1 + + _LABELS = {0: _translate("Form", "与"), 1: _translate("Form", "或")} + _SQL = {0: "and", 1: "or"} + + @classmethod + def display_names(cls): + return [cls._LABELS[cls.And], cls._LABELS[cls.Or]] + + @classmethod + def from_display_name(cls, name: str): + for v, n in cls._LABELS.items(): + if n == name: + return cls(v) + raise ValueError(name) + + def __init__(self, value: int): + self.value = value + + @property + def display_name(self): + return self._LABELS[self.value] + + @property + def to_sql(self): + return self._SQL[self.value] + + +class CompareSymbol: + """比较符号""" + + Equal = 0 + NotEqual = 1 + GreaterThan = 2 + LessThan = 3 + GreaterThanOrEqual = 4 + LessThanOrEqual = 5 + Contains = 6 + NotContains = 7 + + _LABELS = { + 0: _translate("Form", "等于"), + 1: _translate("Form", "不等于"), + 2: _translate("Form", "大于"), + 3: _translate("Form", "小于"), + 4: _translate("Form", "大于等于"), + 5: _translate("Form", "小于等于"), + 6: _translate("Form", "包含"), + 7: _translate("Form", "不包含"), + } + _SQL = { + 0: "=", + 1: "!=", + 2: ">", + 3: "<", + 4: ">=", + 5: "<=", + 6: "in", + 7: "not in", + } + + @classmethod + def display_names(cls): + return [cls._LABELS[i] for i in range(len(cls._LABELS))] + + @classmethod + def from_display_name(cls, name: str): + for v, n in cls._LABELS.items(): + if n == name: + return cls(v) + raise ValueError(name) + + def __init__(self, value: int): + self.value = value + + @property + def display_name(self): + return self._LABELS[self.value] + + @property + def to_sql(self): + return self._SQL[self.value] + + +class HistoryData: + """历史记录数据行(纯数据类,用类属性定义字段)""" + + replay_id: int = 0 + game_board_state: int = 0 + rtime: float = 0 + left: int = 0 + right: int = 0 + double: int = 0 + left_s: float = 0.0 + right_s: float = 0.0 + double_s: float = 0.0 + level: int = 0 + cl: int = 0 + cl_s: float = 0.0 + ce: int = 0 + ce_s: float = 0.0 + rce: int = 0 + lce: int = 0 + dce: int = 0 + bbbv: int = 0 + bbbv_solved: int = 0 + bbbv_s: float = 0.0 + flag: int = 0 + path: float = 0.0 + etime: float = 0 + start_time: int = 0 + end_time: int = 0 + mode: int = 0 + software: str = "" + player_identifier: str = "" + race_identifier: str = "" + uniqueness_identifier: str = "" + stnb: float = 0.0 + corr: float = 0.0 + thrp: float = 0.0 + ioe: float = 0.0 + is_official: int = 0 + is_fair: int = 0 + op: int = 0 + isl: int = 0 + pluck: float = 0.0 + + @classmethod + def get_field_value(cls, field_name: str): + for name, value in inspect.getmembers(cls): + if ( + not name.startswith("__") + and not callable(value) + and not name.startswith("_") + and name == field_name + ): + return value + + @classmethod + def fields(cls): + return [ + name + for name, value in inspect.getmembers(cls) + if not name.startswith("__") + and not callable(value) + and not name.startswith("_") + ] + + @classmethod + def query_all(cls): + return f"select {','.join(cls.fields())} from history" + + @classmethod + def from_dict(cls, data: dict): + instance = cls() + for name, value in inspect.getmembers(cls): + if ( + not name.startswith("__") + and not callable(value) + and not name.startswith("_") + ): + new_value = data.get(name) + # 时间戳字段转换 + if ( + name in ("etime",) + and isinstance(new_value, (int, float)) + and new_value + ): + value = new_value + elif ( + name in ("start_time", "end_time") + and isinstance(new_value, (int, float)) + and new_value + ): + value = datetime.fromtimestamp(new_value / 1_000_000) + elif isinstance(value, float): + value = round(new_value, 4) + else: + value = new_value + setattr(instance, name, value) + return instance diff --git a/src/plugins/history/plugin.py b/src/plugins/history/plugin.py new file mode 100644 index 0000000..867f1da --- /dev/null +++ b/src/plugins/history/plugin.py @@ -0,0 +1,249 @@ +""" +历史记录插件主体 +""" + +from __future__ import annotations + +import base64 +import sqlite3 +from pathlib import Path +from typing import Any + +import msgspec +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QWidget + +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import VideoSaveEvent +from plugins.services.history import HistoryService, GameRecord + +from .widgets import HistoryMainWidget + + +class HistoryPlugin(BasePlugin): + """ + 历史记录插件 + + - 后台:监听 VideoSaveEvent,写入 SQLite + - 界面:提供筛选、分页、播放/导出功能 + - 服务:提供 HistoryService 接口供其他插件查询历史记录 + """ + video_save_over = pyqtSignal() + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="history", + description="游戏历史记录(SQLite 持久化)", + author="ljzloser", + version="1.0.0", + icon=make_plugin_icon("#7b1fa2", "\N{SCROLL}"), + window_mode=WindowMode.TAB, + ) + + def __init__(self, info): + super().__init__(info) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget: + db_path = self.data_dir / "history.db" + config_path = self.data_dir / "history_show_fields.json" + self._widget = HistoryMainWidget(db_path, config_path) + self.video_save_over.connect(self._widget.query_button.click) + return self._widget + + def on_initialized(self) -> None: + self._init_db() + self.register_service(self, protocol=HistoryService) + self.logger.info("历史记录插件已初始化,HistoryService 已注册") + + # ── 数据库 ────────────────────────────────────────────── + + def _init_db(self) -> None: + db_path = self.data_dir / "history.db" + if db_path.exists(): + return + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE history ( + replay_id INTEGER PRIMARY KEY AUTOINCREMENT, + game_board_state INTEGER, + rtime REAL, + left INTEGER, + right INTEGER, + double INTEGER, + left_s REAL, + right_s REAL, + double_s REAL, + level INTEGER, + cl INTEGER, + cl_s REAL, + ce REAL, + ce_s REAL, + rce INTEGER, + lce INTEGER, + dce INTEGER, + bbbv INTEGER, + bbbv_solved INTEGER, + bbbv_s REAL, + flag INTEGER, + path REAL, + etime INTEGER, + start_time INTEGER, + end_time INTEGER, + mode INTEGER, + software TEXT, + player_identifier TEXT, + race_identifier TEXT, + uniqueness_identifier TEXT, + stnb REAL, + corr REAL, + thrp REAL, + ioe REAL, + is_official INTEGER, + is_fair INTEGER, + op INTEGER, + isl INTEGER, + pluck REAL, + raw_data BLOB + ) + """ + ) + conn.commit() + conn.close() + self.logger.info(f"Database created: {db_path}") + + # ── 事件处理 ────────────────────────────────────────── + + def _on_video_save(self, event: VideoSaveEvent) -> None: + data: dict[str, Any] = msgspec.structs.asdict(event) + raw_b64 = data.get("raw_data", "") + try: + data["raw_data"] = base64.b64decode(raw_b64) if raw_b64 else None + except Exception as e: + self.logger.warning(f"base64 decode failed: {e}") + data["raw_data"] = None + del data["timestamp"] + columns = ", ".join(data.keys()) + placeholders = ", ".join(f":{k}" for k in data.keys()) + + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + try: + cursor = conn.cursor() + cursor.execute( + f"INSERT INTO history ({columns}) VALUES ({placeholders})", + data, + ) + conn.commit() + self.logger.info( + f"Saved: board_state={event.game_board_state} time={event.rtime:.1f}s" + ) + finally: + conn.close() + self.video_save_over.emit() + + # ═══════════════════════════════════════════════════════════════════ + # HistoryService 接口实现 + # ═══════════════════════════════════════════════════════════════════ + + def query_records( + self, + limit: int = 100, + offset: int = 0, + level: int | None = None, + ) -> list[GameRecord]: + """查询游戏记录""" + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + try: + if level is not None: + cursor.execute( + """ + SELECT * FROM history + WHERE level = ? + ORDER BY replay_id DESC + LIMIT ? OFFSET ? + """, + (level, limit, offset), + ) + else: + cursor.execute( + """ + SELECT * FROM history + ORDER BY replay_id DESC + LIMIT ? OFFSET ? + """, + (limit, offset), + ) + rows = cursor.fetchall() + return [GameRecord( + replay_id=row["replay_id"], + rtime=row["rtime"], + level=row["level"], + bbbv=row["bbbv"], + bbbv_solved=row["bbbv_solved"], + left=row["left"], + right=row["right"], + double=row["double"], + cl=row["cl"], + ce=row["ce"], + flag=row["flag"], + game_board_state=row["game_board_state"], + mode=row["mode"], + software=row["software"] or "", + start_time=row["start_time"], + end_time=row["end_time"], + ) for row in rows] + finally: + conn.close() + + def get_record_count(self, level: int | None = None) -> int: + """获取记录总数""" + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + if level is not None: + cursor.execute( + "SELECT COUNT(*) FROM history WHERE level = ?", (level,) + ) + else: + cursor.execute("SELECT COUNT(*) FROM history") + return cursor.fetchone()[0] + finally: + conn.close() + + def get_last_record(self) -> GameRecord | None: + """获取最近一条记录""" + records = self.query_records(limit=1) + return records[0] if records else None + + def delete_record(self, record_id: int) -> bool: + """删除指定记录""" + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + cursor.execute( + "DELETE FROM history WHERE replay_id = ?", (record_id,) + ) + conn.commit() + deleted = cursor.rowcount > 0 + if deleted: + self.logger.info(f"Deleted record: {record_id}") + return deleted + finally: + conn.close() + + def _on_config_changed(self, name: str, value: Any) -> None: + return super()._on_config_changed(name, value) diff --git a/src/plugins/history/table_model.py b/src/plugins/history/table_model.py new file mode 100644 index 0000000..07b3e2a --- /dev/null +++ b/src/plugins/history/table_model.py @@ -0,0 +1,76 @@ +""" +表格模型 +""" + +from __future__ import annotations + +from datetime import datetime + +from PyQt5.QtCore import Qt, QAbstractTableModel, QModelIndex + +from .models import HistoryData + + +class HistoryTableModel(QAbstractTableModel): + def __init__( + self, + data: list[HistoryData], + headers: list[str], + show_fields: set[str], + parent=None, + ): + super().__init__(parent) + self._data = data + self._headers = headers + self._show_fields = show_fields + self._visible_headers = [h for h in headers if h in show_fields] + + def rowCount(self, parent=None): + return len(self._data) + + def columnCount(self, parent=None): + return len(self._visible_headers) + + def data(self, index: QModelIndex, role=Qt.DisplayRole): + if not index.isValid(): + return None + row = index.row() + col = index.column() + if row >= len(self._data) or col >= len(self._visible_headers): + return None + + if role == Qt.DisplayRole: + field_name = self._visible_headers[col] + value = getattr(self._data[row], field_name) + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S.%f") + else: + return str(value) + + elif role == Qt.UserRole: + field_name = self._visible_headers[col] + return getattr(self._data[row], field_name) + + elif role == Qt.TextAlignmentRole: + return Qt.AlignCenter | Qt.AlignVCenter + + return None + + def headerData( + self, section: int, orientation: Qt.Orientation, role=Qt.DisplayRole + ): + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + if section < len(self._visible_headers): + return self._visible_headers[section] + return None + + def update_data(self, data: list[HistoryData]): + self.beginResetModel() + self._data = data + self.endResetModel() + + def update_show_fields(self, show_fields: set[str]): + self.beginResetModel() + self._show_fields = show_fields + self._visible_headers = [h for h in self._headers if h in show_fields] + self.endResetModel() diff --git a/src/plugins/history.py b/src/plugins/history/widgets.py similarity index 52% rename from src/plugins/history.py rename to src/plugins/history/widgets.py index 6251ae4..79720fa 100644 --- a/src/plugins/history.py +++ b/src/plugins/history/widgets.py @@ -1,41 +1,19 @@ """ -历史记录插件 - -功能: -- 监听 VideoSaveEvent,将游戏录像数据持久化到 SQLite 数据库 -- 提供 GUI 界面:表格浏览、筛选、分页、播放/导出录像 -- 使用 self.data_dir 存储数据库文件(每个插件独立目录) +UI 组件 """ from __future__ import annotations -import base64 import json import math import sqlite3 import subprocess import sys -import inspect from datetime import datetime from pathlib import Path -import time from typing import Any -import msgspec - -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode -from plugin_manager.app_paths import get_executable_dir -from shared_types.events import VideoSaveEvent -from shared_types.services.history import HistoryService, GameRecord - -from PyQt5.QtCore import ( - QObject, - Qt, - QCoreApplication, - QAbstractTableModel, - QModelIndex, - pyqtSignal, -) +from PyQt5.QtCore import Qt, QCoreApplication from PyQt5.QtGui import QCloseEvent as _QCloseEvent from PyQt5.QtWidgets import ( QWidget, @@ -43,8 +21,6 @@ QTableWidget, QMenu, QAction, - QTableWidgetItem, - QHeaderView, QTableView, QMessageBox, QFileDialog, @@ -58,272 +34,20 @@ QSpacerItem, QSizePolicy, QLabel, + QHeaderView, ) -_translate = QCoreApplication.translate - - -# ── 枚举定义(替代原 utils 中的枚举)───────────────────── - - -class LogicSymbol: - And = 0 - Or = 1 - - _LABELS = {0: _translate("Form", "与"), 1: _translate("Form", "或")} - _SQL = {0: "and", 1: "or"} - - @classmethod - def display_names(cls): - return [cls._LABELS[cls.And], cls._LABELS[cls.Or]] - - @classmethod - def from_display_name(cls, name: str): - for v, n in cls._LABELS.items(): - if n == name: - return cls(v) - raise ValueError(name) - - def __init__(self, value: int): - self.value = value - - @property - def display_name(self): - return self._LABELS[self.value] - - @property - def to_sql(self): - return self._SQL[self.value] - - -class CompareSymbol: - Equal = 0 - NotEqual = 1 - GreaterThan = 2 - LessThan = 3 - GreaterThanOrEqual = 4 - LessThanOrEqual = 5 - Contains = 6 - NotContains = 7 - - _LABELS = { - 0: _translate("Form", "等于"), - 1: _translate("Form", "不等于"), - 2: _translate("Form", "大于"), - 3: _translate("Form", "小于"), - 4: _translate("Form", "大于等于"), - 5: _translate("Form", "小于等于"), - 6: _translate("Form", "包含"), - 7: _translate("Form", "不包含"), - } - _SQL = { - 0: "=", - 1: "!=", - 2: ">", - 3: "<", - 4: ">=", - 5: "<=", - 6: "in", - 7: "not in", - } - - @classmethod - def display_names(cls): - return [cls._LABELS[i] for i in range(len(cls._LABELS))] - - @classmethod - def from_display_name(cls, name: str): - for v, n in cls._LABELS.items(): - if n == name: - return cls(v) - raise ValueError(name) - - def __init__(self, value: int): - self.value = value - - @property - def display_name(self): - return self._LABELS[self.value] - - @property - def to_sql(self): - return self._SQL[self.value] - - -# ── 数据模型 ─────────────────────────────────────────────── - - -class HistoryData: - """历史记录数据行(纯数据类,用类属性定义字段)""" - - replay_id: int = 0 - game_board_state: int = 0 - rtime: float = 0 - left: int = 0 - right: int = 0 - double: int = 0 - left_s: float = 0.0 - right_s: float = 0.0 - double_s: float = 0.0 - level: int = 0 - cl: int = 0 - cl_s: float = 0.0 - ce: int = 0 - ce_s: float = 0.0 - rce: int = 0 - lce: int = 0 - dce: int = 0 - bbbv: int = 0 - bbbv_solved: int = 0 - bbbv_s: float = 0.0 - flag: int = 0 - path: float = 0.0 - etime: float = 0 - start_time: int = 0 - end_time: int = 0 - mode: int = 0 - software: str = "" - player_identifier: str = "" - race_identifier: str = "" - uniqueness_identifier: str = "" - stnb: float = 0.0 - corr: float = 0.0 - thrp: float = 0.0 - ioe: float = 0.0 - is_official: int = 0 - is_fair: int = 0 - op: int = 0 - isl: int = 0 - pluck: float = 0.0 - - @classmethod - def get_field_value(cls, field_name: str): - for name, value in inspect.getmembers(cls): - if ( - not name.startswith("__") - and not callable(value) - and not name.startswith("_") - and name == field_name - ): - return value - - @classmethod - def fields(cls): - return [ - name - for name, value in inspect.getmembers(cls) - if not name.startswith("__") - and not callable(value) - and not name.startswith("_") - ] - - @classmethod - def query_all(cls): - return f"select {','.join(cls.fields())} from history" - - @classmethod - def from_dict(cls, data: dict): - instance = cls() - for name, value in inspect.getmembers(cls): - if ( - not name.startswith("__") - and not callable(value) - and not name.startswith("_") - ): - new_value = data.get(name) - # 时间戳字段转换 - if ( - name in ("etime",) - and isinstance(new_value, (int, float)) - and new_value - ): - value = new_value - elif ( - name in ("start_time", "end_time") - and isinstance(new_value, (int, float)) - and new_value - ): - value = datetime.fromtimestamp(new_value / 1_000_000) - elif isinstance(value, float): - value = round(new_value, 4) - else: - value = new_value - setattr(instance, name, value) - return instance - - -# ── Table Model ──────────────────────────────────────────── - - -class HistoryTableModel(QAbstractTableModel): - def __init__( - self, - data: list[HistoryData], - headers: list[str], - show_fields: set[str], - parent=None, - ): - super().__init__(parent) - self._data = data - self._headers = headers - self._show_fields = show_fields - self._visible_headers = [h for h in headers if h in show_fields] - - def rowCount(self, parent=None): - return len(self._data) - - def columnCount(self, parent=None): - return len(self._visible_headers) - - def data(self, index: QModelIndex, role=Qt.DisplayRole): - if not index.isValid(): - return None - row = index.row() - col = index.column() - if row >= len(self._data) or col >= len(self._visible_headers): - return None - - if role == Qt.DisplayRole: - field_name = self._visible_headers[col] - value = getattr(self._data[row], field_name) - if isinstance(value, datetime): - return value.strftime("%Y-%m-%d %H:%M:%S.%f") - else: - return str(value) - - elif role == Qt.UserRole: - field_name = self._visible_headers[col] - return getattr(self._data[row], field_name) - - elif role == Qt.TextAlignmentRole: - return Qt.AlignCenter | Qt.AlignVCenter - - return None - - def headerData( - self, section: int, orientation: Qt.Orientation, role=Qt.DisplayRole - ): - if role == Qt.DisplayRole and orientation == Qt.Horizontal: - if section < len(self._visible_headers): - return self._visible_headers[section] - return None - - def update_data(self, data: list[HistoryData]): - self.beginResetModel() - self._data = data - self.endResetModel() - - def update_show_fields(self, show_fields: set[str]): - self.beginResetModel() - self._show_fields = show_fields - self._visible_headers = [h for h in self._headers if h in show_fields] - self.endResetModel() +from plugin_manager.app_paths import get_executable_dir +from .models import HistoryData, LogicSymbol, CompareSymbol +from .table_model import HistoryTableModel -# ── 筛选控件 ────────────────────────────────────────────── +_translate = QCoreApplication.translate class FilterWidget(QWidget): + """筛选条件控件""" + def __init__(self, parent=None): super().__init__(parent) vbox = QVBoxLayout(self) @@ -535,10 +259,9 @@ def gen_filter_str(self): return filter_str -# ── 历史记录表格 ────────────────────────────────────────── - - class HistoryTable(QWidget): + """历史记录表格""" + HEADERS = [ "replay_id", "game_board_state", @@ -672,7 +395,7 @@ def play_row(self): temp_filename = exec_dir / "tmp.evf" self.save_evf(str(temp_filename)) - exe = exec_dir / "metaminsweeper.exe" + exe = exec_dir / "metaminesweeper.exe" main_py = exec_dir / "main.py" if main_py.exists(): @@ -682,7 +405,7 @@ def play_row(self): subprocess.Popen([str(exe), str(temp_filename)]) else: QMessageBox.warning( - self, "错误", "找不到主程序 (main.py 或 metaminsweeper.exe)" + self, "错误", "找不到主程序 (main.py 或 metaminesweeper.exe)" ) def export_row(self): @@ -696,9 +419,6 @@ def export_row(self): self.save_evf(file_path) -# ── 主界面容器 ──────────────────────────────────────────── - - class HistoryMainWidget(QWidget): """历史记录插件的主界面(作为插件的 widget 返回)""" @@ -829,237 +549,3 @@ def closeEvent(self, event: _QCloseEvent): with open(self._config_path, "w") as f: json.dump(list(self.table.showFields), f) super().closeEvent(event) - - -# ═══════════════════════════════════════════════════════════════════ -# 插件主体 -# ═══════════════════════════════════════════════════════════════════ - - -class HistoryPlugin(BasePlugin): - """ - 历史记录插件 - - - 后台:监听 VideoSaveEvent,写入 SQLite - - 界面:提供筛选、分页、播放/导出功能 - - 服务:提供 HistoryService 接口供其他插件查询历史记录 - """ - video_save_over = pyqtSignal() - - @classmethod - def plugin_info(cls) -> PluginInfo: - return PluginInfo( - name="history", - description="游戏历史记录(SQLite 持久化)", - author="ljzloser", - version="1.0.0", - icon=make_plugin_icon("#7b1fa2", "\N{SCROLL}"), - window_mode=WindowMode.TAB, - ) - - def __init__(self, info): - super().__init__(info) - - def _setup_subscriptions(self) -> None: - self.subscribe(VideoSaveEvent, self._on_video_save) - - def _create_widget(self) -> QWidget: - db_path = self.data_dir / "history.db" - config_path = self.data_dir / "history_show_fields.json" - self._widget = HistoryMainWidget(db_path, config_path) - self.video_save_over.connect(self._widget.query_button.click) - return self._widget - - def on_initialized(self) -> None: - self._init_db() - self.register_service(self, protocol=HistoryService) - self.logger.info("历史记录插件已初始化,HistoryService 已注册") - - # ── 数据库 ────────────────────────────────────────────── - - def _init_db(self) -> None: - db_path = self.data_dir / "history.db" - if db_path.exists(): - return - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - cursor.execute( - """ - CREATE TABLE history ( - replay_id INTEGER PRIMARY KEY AUTOINCREMENT, - game_board_state INTEGER, - rtime REAL, - left INTEGER, - right INTEGER, - double INTEGER, - left_s REAL, - right_s REAL, - double_s REAL, - level INTEGER, - cl INTEGER, - cl_s REAL, - ce REAL, - ce_s REAL, - rce INTEGER, - lce INTEGER, - dce INTEGER, - bbbv INTEGER, - bbbv_solved INTEGER, - bbbv_s REAL, - flag INTEGER, - path REAL, - etime INTEGER, - start_time INTEGER, - end_time INTEGER, - mode INTEGER, - software TEXT, - player_identifier TEXT, - race_identifier TEXT, - uniqueness_identifier TEXT, - stnb REAL, - corr REAL, - thrp REAL, - ioe REAL, - is_official INTEGER, - is_fair INTEGER, - op INTEGER, - isl INTEGER, - pluck REAL, - raw_data BLOB - ) - """ - ) - conn.commit() - conn.close() - self.logger.info(f"Database created: {db_path}") - - # ── 事件处理 ────────────────────────────────────────── - - def _on_video_save(self, event: VideoSaveEvent) -> None: - data: dict[str, Any] = msgspec.structs.asdict(event) - raw_b64 = data.get("raw_data", "") - try: - data["raw_data"] = base64.b64decode(raw_b64) if raw_b64 else None - except Exception as e: - self.logger.warning(f"base64 decode failed: {e}") - data["raw_data"] = None - del data["timestamp"] - columns = ", ".join(data.keys()) - placeholders = ", ".join(f":{k}" for k in data.keys()) - - db_path = self.data_dir / "history.db" - conn = sqlite3.connect(db_path) - try: - cursor = conn.cursor() - cursor.execute( - f"INSERT INTO history ({columns}) VALUES ({placeholders})", - data, - ) - conn.commit() - self.logger.info( - f"Saved: board_state={event.game_board_state} time={event.rtime:.1f}s" - ) - finally: - conn.close() - self.video_save_over.emit() - - # ═══════════════════════════════════════════════════════════════════ - # HistoryService 接口实现 - # ═══════════════════════════════════════════════════════════════════ - - def query_records( - self, - limit: int = 100, - offset: int = 0, - level: int | None = None, - ) -> list[GameRecord]: - """查询游戏记录""" - db_path = self.data_dir / "history.db" - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - try: - if level is not None: - cursor.execute( - """ - SELECT * FROM history - WHERE level = ? - ORDER BY replay_id DESC - LIMIT ? OFFSET ? - """, - (level, limit, offset), - ) - else: - cursor.execute( - """ - SELECT * FROM history - ORDER BY replay_id DESC - LIMIT ? OFFSET ? - """, - (limit, offset), - ) - rows = cursor.fetchall() - return [GameRecord( - replay_id=row["replay_id"], - rtime=row["rtime"], - level=row["level"], - bbbv=row["bbbv"], - bbbv_solved=row["bbbv_solved"], - left=row["left"], - right=row["right"], - double=row["double"], - cl=row["cl"], - ce=row["ce"], - flag=row["flag"], - game_board_state=row["game_board_state"], - mode=row["mode"], - software=row["software"] or "", - start_time=row["start_time"], - end_time=row["end_time"], - ) for row in rows] - finally: - conn.close() - - def get_record_count(self, level: int | None = None) -> int: - """获取记录总数""" - db_path = self.data_dir / "history.db" - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - try: - if level is not None: - cursor.execute( - "SELECT COUNT(*) FROM history WHERE level = ?", (level,) - ) - else: - cursor.execute("SELECT COUNT(*) FROM history") - return cursor.fetchone()[0] - finally: - conn.close() - - def get_last_record(self) -> GameRecord | None: - """获取最近一条记录""" - records = self.query_records(limit=1) - return records[0] if records else None - - def delete_record(self, record_id: int) -> bool: - """删除指定记录""" - db_path = self.data_dir / "history.db" - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - try: - cursor.execute( - "DELETE FROM history WHERE replay_id = ?", (record_id,) - ) - conn.commit() - deleted = cursor.rowcount > 0 - if deleted: - self.logger.info(f"Deleted record: {record_id}") - return deleted - finally: - conn.close() - - def _on_config_changed(self, name: str, value: Any) -> None: - return super()._on_config_changed(name, value) diff --git a/src/shared_types/services/history.py b/src/plugins/services/history.py similarity index 100% rename from src/shared_types/services/history.py rename to src/plugins/services/history.py diff --git a/src/plugins/stats_panel.py b/src/plugins/stats_panel.py index 5404313..e9d5bf7 100644 --- a/src/plugins/stats_panel.py +++ b/src/plugins/stats_panel.py @@ -14,9 +14,9 @@ ) from PyQt5.QtCore import pyqtSignal -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode from shared_types.events import VideoSaveEvent -from shared_types.services.history import HistoryService, GameRecord +from plugins.services.history import HistoryService, GameRecord class StatsPanel(QWidget): @@ -28,7 +28,8 @@ def __init__(self, parent=None): super().__init__(parent) self._total_games = 0 self._best_time = float('inf') - self._stats_by_level = defaultdict(lambda: {"count": 0, "best_time": float('inf')}) + self._stats_by_level = defaultdict( + lambda: {"count": 0, "best_time": float('inf')}) self._setup_ui() self._signal_refresh.connect(self._do_refresh) @@ -51,7 +52,8 @@ def _setup_ui(self): self._table = QTableWidget() self._table.setColumnCount(4) - self._table.setHorizontalHeaderLabels(["Level", "Time(s)", "3BV", "Clicks"]) + self._table.setHorizontalHeaderLabels( + ["Level", "Time(s)", "3BV", "Clicks"]) self._table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self._table.setAlternatingRowColors(True) self._table.setSelectionBehavior(QTableWidget.SelectRows) @@ -62,14 +64,17 @@ def _setup_ui(self): @staticmethod def _make_stat_card(title: str, value: str, color: str) -> QWidget: card = QWidget() - card.setStyleSheet(f"background: {color}; border-radius: 8px; padding: 8px;") + card.setStyleSheet( + f"background: {color}; border-radius: 8px; padding: 8px;") layout = QVBoxLayout(card) layout.setContentsMargins(12, 8, 12, 8) lbl_title = QLabel(title) - lbl_title.setStyleSheet("color: rgba(255,255,255,0.8); font-size: 12px;") + lbl_title.setStyleSheet( + "color: rgba(255,255,255,0.8); font-size: 12px;") lbl_value = QLabel(value) - lbl_value.setStyleSheet("color: white; font-size: 24px; font-weight: bold;") + lbl_value.setStyleSheet( + "color: white; font-size: 24px; font-weight: bold;") layout.addWidget(lbl_title) layout.addWidget(lbl_value) @@ -132,7 +137,8 @@ def _create_widget(self) -> QWidget: def on_initialized(self) -> None: # 检查 HistoryService 是否可用 - if self.has_service(HistoryService): + history = self.wait_for_service(HistoryService, 10) + if history is not None: self.logger.info("HistoryService 已连接") self._load_history_stats() else: @@ -143,7 +149,7 @@ def _load_history_stats(self) -> None: try: # 获取服务代理对象(IDE 友好) history = self.get_service_proxy(HistoryService) - + # 直接调用方法(IDE 完整补全) total = history.get_record_count() self.logger.info(f"历史记录总数: {total}") diff --git a/src/plugins/test_control_a.py b/src/plugins/test_control_a.py new file mode 100644 index 0000000..aed4bbf --- /dev/null +++ b/src/plugins/test_control_a.py @@ -0,0 +1,108 @@ +""" +测试控制插件 A + +声明需要 NewGameCommand 控制权限 +""" +from __future__ import annotations + +from PyQt5.QtWidgets import QMessageBox, QWidget, QVBoxLayout, QLabel, QPushButton +from PyQt5.QtCore import pyqtSignal + +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.commands import NewGameCommand +from shared_types.events import VideoSaveEvent + + +class TestControlWidgetA(QWidget): + """测试插件 A 的界面""" + + def __init__(self, parent=None): + super().__init__(parent) + + layout = QVBoxLayout(self) + + self._title = QLabel("测试控制插件 A") + self._title.setStyleSheet("font-size: 16px; font-weight: bold;") + layout.addWidget(self._title) + + self._status = QLabel("状态: 等待初始化...") + layout.addWidget(self._status) + + self._btn = QPushButton("开始新游戏 (16x30x99)") + self._btn.setEnabled(False) + layout.addWidget(self._btn) + + self._btn.clicked.connect(self._on_click) + + def set_has_permission(self, has: bool) -> None: + if has: + self._status.setText("状态: ✅ 已获得 NewGameCommand 权限") + self._btn.setEnabled(True) + else: + self._status.setText("状态: ❌ 未获得 NewGameCommand 权限") + self._btn.setEnabled(False) + + def _on_click(self) -> None: + # 由插件连接 + pass + + +class TestControlPluginA(BasePlugin): + """测试控制插件 A""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="test_control_a", + version="1.0.0", + author="Test", + description="测试控制权限 A - NewGameCommand", + icon=make_plugin_icon("#e91e63", "A"), + window_mode=WindowMode.TAB, + required_controls=[NewGameCommand], # 声明需要的控制权限 + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget: + self._widget = TestControlWidgetA() + self._widget._btn.clicked.connect(self._on_new_game_click) + return self._widget + + def on_initialized(self) -> None: + self.logger.info("TestControlPluginA 初始化") + + # 检查是否有控制权限 + has_auth = self.has_control_auth(NewGameCommand) + self.logger.info(f"NewGameCommand 权限: {has_auth}") + + # 更新界面 + self.run_on_gui(self._widget.set_has_permission, has_auth) + + def on_control_auth_changed(self, command_type: type, granted: bool) -> None: + """权限变更回调""" + if command_type == NewGameCommand: + self.logger.info(f"NewGameCommand 权限变更: {granted}") + self.run_on_gui(self._widget.set_has_permission, granted) + + def _on_new_game_click(self) -> None: + if self.has_control_auth(NewGameCommand): + self.logger.info("发送 NewGameCommand") + result = self.request(NewGameCommand(rows=16, cols=30, mines=99)) + if result is not None: + QMessageBox.information( + self.widget, "NewGameCommand 响应", f"请求 ID: {result.request_id}, 成功: {result.success}") + else: + self.logger.warning("没有 NewGameCommand 权限") + + def _on_video_save(self, event: VideoSaveEvent) -> None: + self.logger.info(f"收到游戏结束事件: {event.rtime}s") + + def on_shutdown(self) -> None: + self.logger.info("TestControlPluginA 关闭") + + def on_control_auth_changed(self, command_type: type, granted: bool) -> None: + # if command_type == NewGameCommand: + # self.run_on_gui(self._widget.set_has_permission, granted) + pass diff --git a/src/plugins/test_control_b.py b/src/plugins/test_control_b.py new file mode 100644 index 0000000..7b5c168 --- /dev/null +++ b/src/plugins/test_control_b.py @@ -0,0 +1,127 @@ +""" +测试控制插件 B + +声明需要 NewGameCommand 控制权限 +""" +from __future__ import annotations + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QSpinBox +from PyQt5.QtCore import pyqtSignal, Qt + +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.commands import NewGameCommand +from shared_types.events import VideoSaveEvent + + +class TestControlWidgetB(QWidget): + """测试插件 B 的界面""" + + def __init__(self, parent=None): + super().__init__(parent) + + layout = QVBoxLayout(self) + + self._title = QLabel("测试控制插件 B") + self._title.setStyleSheet("font-size: 16px; font-weight: bold;") + layout.addWidget(self._title) + + self._status = QLabel("状态: 等待初始化...") + layout.addWidget(self._status) + + # 游戏参数输入 + self._rows_spin = QSpinBox() + self._rows_spin.setRange(1, 100) + self._rows_spin.setValue(16) + layout.addWidget(QLabel("行数:")) + layout.addWidget(self._rows_spin) + + self._cols_spin = QSpinBox() + self._cols_spin.setRange(1, 100) + self._cols_spin.setValue(30) + layout.addWidget(QLabel("列数:")) + layout.addWidget(self._cols_spin) + + self._mines_spin = QSpinBox() + self._mines_spin.setRange(1, 999) + self._mines_spin.setValue(99) + layout.addWidget(QLabel("雷数:")) + layout.addWidget(self._mines_spin) + + self._btn = QPushButton("开始新游戏") + self._btn.setEnabled(False) + layout.addWidget(self._btn) + + self._btn.clicked.connect(self._on_click) + + def set_has_permission(self, has: bool) -> None: + if has: + self._status.setText("状态: ✅ 已获得 NewGameCommand 权限") + self._btn.setEnabled(True) + else: + self._status.setText("状态: ❌ 未获得 NewGameCommand 权限") + self._btn.setEnabled(False) + + def get_params(self) -> tuple[int, int, int]: + return ( + self._rows_spin.value(), + self._cols_spin.value(), + self._mines_spin.value(), + ) + + def _on_click(self) -> None: + # 由插件连接 + pass + + +class TestControlPluginB(BasePlugin): + """测试控制插件 B""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="test_control_b", + version="1.0.0", + author="Test", + description="测试控制权限 B - NewGameCommand", + icon=make_plugin_icon("#2196f3", "B"), + window_mode=WindowMode.TAB, + required_controls=[NewGameCommand], # 声明需要的控制权限 + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget: + self._widget = TestControlWidgetB() + self._widget._btn.clicked.connect(self._on_new_game_click) + return self._widget + + def on_initialized(self) -> None: + self.logger.info("TestControlPluginB 初始化") + + # 检查是否有控制权限 + has_auth = self.has_control_auth(NewGameCommand) + self.logger.info(f"NewGameCommand 权限: {has_auth}") + + # 更新界面 + self.run_on_gui(self._widget.set_has_permission, has_auth) + + def on_control_auth_changed(self, command_type: type, granted: bool) -> None: + """权限变更回调""" + if command_type == NewGameCommand: + self.logger.info(f"NewGameCommand 权限变更: {granted}") + self.run_on_gui(self._widget.set_has_permission, granted) + + def _on_new_game_click(self) -> None: + rows, cols, mines = self._widget.get_params() + if self.has_control_auth(NewGameCommand): + self.logger.info(f"发送 NewGameCommand: {rows}x{cols}x{mines}") + self.send_command(NewGameCommand(rows=rows, cols=cols, mines=mines)) + else: + self.logger.warning("没有 NewGameCommand 权限") + + def _on_video_save(self, event: VideoSaveEvent) -> None: + self.logger.info(f"收到游戏结束事件: {event.rtime}s") + + def on_shutdown(self) -> None: + self.logger.info("TestControlPluginB 关闭") \ No newline at end of file