diff --git a/.gitignore b/.gitignore index 8ba53a1..2f65c51 100644 --- a/.gitignore +++ b/.gitignore @@ -169,6 +169,4 @@ old/ src/plugins/*/*.json src/plugins/*/*.db .vscode/ -src/history.db -history_show_fields.json -src/out.json +data/* \ No newline at end of file diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..8e74180 --- /dev/null +++ b/build.bat @@ -0,0 +1,36 @@ +@echo off +chcp 65001 >nul 2>&1 + +set OUT=dist\app +set DEST=%OUT%\metaminsweeper + +echo [Clean] Removing old builds... +if exist "%OUT%\metaminsweeper" rmdir /s /q "%OUT%\metaminsweeper" +if exist "%OUT%\plugin_manager" rmdir /s /q "%OUT%\plugin_manager" + +echo. +echo [1/3] metaminsweeper.exe +pyinstaller --noconfirm --name metaminsweeper --windowed --distpath %OUT% src\main.py + +echo. +echo [2/3] plugin_manager.exe +pyinstaller --noconfirm --name plugin_manager --windowed --hidden-import code --hidden-import xmlrpc --hidden-import xmlrpc.server --hidden-import xmlrpc.client --hidden-import http.server --hidden-import socketserver --hidden-import email --hidden-import email.utils --distpath %OUT% src\plugin_manager\_run.py + +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_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 +if exist "%DEST%\plugin_manager\_run.py" del /f /q "%DEST%\plugin_manager\_run.py" + +echo [4/4] Copy debugpy from venv to _internal\ +set SP=.venv\Lib\site-packages +xcopy /e /y /i "%SP%\debugpy" "%DEST%\_internal\debugpy" >nul +xcopy /e /y /i "%SP%\msgspec" "%DEST%\_internal\msgspec" >nul 2>nul +xcopy /e /y /i "%SP%\setuptools" "%DEST%\_internal\setuptools" >nul 2>nul + +echo. +echo Done! Both in: %OUT%\ +pause diff --git a/hook-debugpy-pyinstaller.py b/hook-debugpy-pyinstaller.py new file mode 100644 index 0000000..8497e7c --- /dev/null +++ b/hook-debugpy-pyinstaller.py @@ -0,0 +1,26 @@ +""" +PyInstaller runtime hook for debugpy compatibility. +在 import debugpy 之前修复路径问题,确保 _vendored 目录可被找到。 +""" +import os +import sys + +# PyInstaller 打包后 __file__ 指向临时目录或 zip 内, +# debugpy/_vendored/__init__.py 用 os.path.abspath(__file__) 定位资源会失败。 +# 此 hook 在任何代码执行前将 debugpy 的真实解压路径注入 sys._MEIPASS 搜索逻辑。 + +def _fix_debugpy_paths(): + # PyInstaller 运行时,已解压的文件在 sys._MEIPASS 下 + meipass = getattr(sys, "_MEIPASS", None) + if not meipass: + return # 非打包环境,不需要处理 + + debugpy_dir = os.path.join(meipass, "debugpy") + vendored_dir = os.path.join(debugpy_dir, "_vendored") + + if os.path.isdir(debugpy_dir): + # 确保 debugpy 在 sys.path 中靠前(PyInstaller 已处理,但保险起见) + if debugpy_dir not in sys.path: + sys.path.insert(0, debugpy_dir) + +_fix_debugpy_paths() diff --git a/requirements.txt b/requirements.txt index eae959b..74532e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ setuptools==80.9.0 msgspec>=0.20.0 zmq>=0.0.0 pywin32 - +loguru \ No newline at end of file diff --git a/src/lib_zmq_plugins/__init__.py b/src/lib_zmq_plugins/__init__.py new file mode 100644 index 0000000..396fbee --- /dev/null +++ b/src/lib_zmq_plugins/__init__.py @@ -0,0 +1,7 @@ +from lib_zmq_plugins.shared.base import BaseCommand, BaseEvent, CommandResponse, SyncCommand +from lib_zmq_plugins.log import LogHandler, NullHandler + +__all__ = [ + "BaseEvent", "BaseCommand", "CommandResponse", "SyncCommand", + "LogHandler", "NullHandler", +] diff --git a/src/lib_zmq_plugins/client/__init__.py b/src/lib_zmq_plugins/client/__init__.py new file mode 100644 index 0000000..4b7e7bf --- /dev/null +++ b/src/lib_zmq_plugins/client/__init__.py @@ -0,0 +1,3 @@ +from lib_zmq_plugins.client.zmq_client import ZMQClient + +__all__ = ["ZMQClient"] diff --git a/src/lib_zmq_plugins/client/zmq_client.py b/src/lib_zmq_plugins/client/zmq_client.py new file mode 100644 index 0000000..e08f0b2 --- /dev/null +++ b/src/lib_zmq_plugins/client/zmq_client.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import threading +import uuid +from concurrent.futures import Future +from typing import Any, Callable +from urllib.parse import urlparse + +import zmq + +from lib_zmq_plugins.log import LogHandler, NullHandler +from lib_zmq_plugins.serializer import Serializer +from lib_zmq_plugins.shared.base import ( + BaseCommand, + BaseEvent, + CommandResponse, + SyncCommand, + get_event_tag, +) + + +def _derive_endpoints(base: str) -> tuple[str, str]: + """根据基础 endpoint 派生 PUB 和 CTRL 地址""" + parsed = urlparse(base) + if parsed.scheme == "ipc": + path = parsed.path + return ( + f"ipc://{path}_pub", + f"ipc://{path}_ctrl", + ) + elif parsed.scheme == "tcp": + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 5555 + return ( + f"tcp://{host}:{port + 1}", + f"tcp://{host}:{port + 2}", + ) + else: + raise ValueError(f"Unsupported scheme: {parsed.scheme}") + + +class ZMQClient: + """ZMQ 客户端,运行在插件管理器进程中""" + + def __init__( + self, + endpoint: str, + on_connected: Callable[[], Any] | None = None, + on_disconnected: Callable[[], Any] | None = None, + log_handler: LogHandler | None = None, + ) -> None: + self._endpoint = endpoint + self._pub_endpoint, self._ctrl_endpoint = _derive_endpoints(endpoint) + self._serializer = Serializer() + self._serializer.register_command_types(SyncCommand) + self._log: LogHandler = log_handler or NullHandler() + + self._subscribers: dict[str, list[Callable[[BaseEvent], None]]] = {} + self._pending_requests: dict[str, Future[CommandResponse]] = {} + self._pending_lock = threading.Lock() + self._sync_topics: dict[str, str] = {} # request_id → topic + + self._ctx: zmq.Context | None = None + self._sub_socket: zmq.Socket | None = None + self._dealer_socket: zmq.Socket | None = None + self._poller: zmq.Poller | None = None + self._thread: threading.Thread | None = None + self._stopped = threading.Event() + + self.on_connected: Callable[[], Any] | None = on_connected + self.on_disconnected: Callable[[], Any] | None = on_disconnected + + # 连接状态追踪 + self._is_connected = False + self._reconnect_count = 0 + self._has_ever_connected = False # 是否曾经真正连接过服务端 + + # ── 类型注册 ── + + def register_event_types(self, *types: type[BaseEvent]) -> None: + self._serializer.register_event_types(*types) + + def register_command_types(self, *types: type[BaseCommand]) -> None: + self._serializer.register_command_types(*types) + + # ── 连接管理 ── + + def connect(self) -> None: + self._stopped.clear() + self._ctx = zmq.Context() + self._create_sockets() + + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + + # 不在此处标记已连接 —— ZMQ connect 是非阻塞的, + # 即使服务端不存在也不会报错。 + # 改为延迟确认:首次收到服务端响应 / 心跳成功后, + # 由 _on_first_activity() 触发 on_connected。 + + for topic in list(self._subscribers): + self._request_snapshot(topic) + + # 启动后立即发一次心跳作为探测 + self._probe_connection() + + @property + def is_connected(self) -> bool: + """当前连接状态""" + return self._is_connected + + @property + def reconnect_count(self) -> int: + """重连次数""" + return self._reconnect_count + + @property + def endpoint(self) -> str: + """当前端点地址""" + return self._endpoint + + @property + def pub_endpoint(self) -> str: + """PUB 端点地址""" + return self._pub_endpoint + + @property + def ctrl_endpoint(self) -> str: + """CTRL 端点地址""" + return self._ctrl_endpoint + + def disconnect(self) -> None: + self._stopped.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=3.0) + self._close_sockets() + if self._ctx: + self._ctx.term() + self._ctx = None + self._thread = None + self._is_connected = False + + def _create_sockets(self) -> None: + if self._ctx is None: + return + self._sub_socket = self._ctx.socket(zmq.SUB) + self._dealer_socket = self._ctx.socket(zmq.DEALER) + self._dealer_socket.setsockopt_string(zmq.IDENTITY, uuid.uuid4().hex) + + # 启用 ZMQ 原生心跳,自动检测服务端断开 + # HEARTBEAT_IVL: 每 5 秒发送一次心跳 + # HEARTBEAT_TIMEOUT: 5 秒内没收到回复视为断连 + # HEARTBEAT_TTL: 心跳包存活时间 + self._dealer_socket.setsockopt(zmq.HEARTBEAT_IVL, 5000) + self._dealer_socket.setsockopt(zmq.HEARTBEAT_TIMEOUT, 5000) + self._dealer_socket.setsockopt(zmq.HEARTBEAT_TTL, 10000) + + self._sub_socket.connect(self._pub_endpoint) + self._dealer_socket.connect(self._ctrl_endpoint) + + self._poller = zmq.Poller() + self._poller.register(self._sub_socket, zmq.POLLIN) + self._poller.register(self._dealer_socket, zmq.POLLIN) + + for topic in self._subscribers: + self._sub_socket.setsockopt_string(zmq.SUBSCRIBE, topic) + + def _close_sockets(self) -> None: + if self._sub_socket: + self._sub_socket.close(linger=0) + if self._dealer_socket: + self._dealer_socket.close(linger=0) + self._sub_socket = None + self._dealer_socket = None + self._poller = None + + # ── 事件订阅 ── + + def subscribe(self, topic: type[BaseEvent], callback: Callable[[BaseEvent], None]) -> None: + tag = get_event_tag(topic) + if tag not in self._subscribers: + self._subscribers[tag] = [] + self._subscribers[tag].append(callback) + if self._sub_socket: + self._sub_socket.setsockopt_string(zmq.SUBSCRIBE, tag) + self._request_snapshot(tag) + + def unsubscribe(self, topic: type[BaseEvent]) -> None: + tag = get_event_tag(topic) + self._subscribers.pop(tag, None) + if self._sub_socket: + self._sub_socket.setsockopt_string(zmq.UNSUBSCRIBE, tag) + + def _request_snapshot(self, topic: str) -> None: + """异步发送快照同步请求""" + if self._dealer_socket is None: + return + rid = uuid.uuid4().hex + self._sync_topics[rid] = topic + cmd = SyncCommand(request_id=rid, topic=topic) + payload = self._serializer.encode_command(cmd) + try: + self._dealer_socket.send(payload) + except zmq.ZMQError: + self._log.warning("Failed to send sync request for topic: %s", topic) + self._sync_topics.pop(rid, None) + + # ── 指令发送(可在任意线程调用) ── + + def send_command(self, cmd: BaseCommand) -> None: + if self._dealer_socket is None: + raise RuntimeError("Client is not connected") + payload = self._serializer.encode_command(cmd) + self._dealer_socket.send(payload) + + def request( + self, cmd: BaseCommand, timeout: float = 5.0 + ) -> CommandResponse: + if self._dealer_socket is None: + raise RuntimeError("Client is not connected") + cmd.request_id = uuid.uuid4().hex + future: Future[CommandResponse] = Future() + with self._pending_lock: + self._pending_requests[cmd.request_id] = future + payload = self._serializer.encode_command(cmd) + self._dealer_socket.send(payload) + return future.result(timeout=timeout) + + # ── 后台轮询 ── + + def _poll_loop(self) -> None: + while not self._stopped.is_set(): + try: + events = self._poller.poll(timeout=200) + except zmq.ZMQError: + self._handle_reconnect(0.1) + continue + + if not events: + continue + + for socket, _ in events: + try: + if socket is self._sub_socket: + self._handle_sub_message() + elif socket is self._dealer_socket: + self._handle_dealer_message() + # 收到消息 = 服务端存活,确认连接 + self._on_first_activity() + except zmq.ZMQError as e: + self._log.warning("Socket error during recv: %s", e) + self._handle_reconnect(0.5) + + def _on_first_activity(self) -> None: + """首次收到服务端数据时确认连接(防止虚假已连)""" + if self._is_connected: + return + self._is_connected = True + self._has_ever_connected = True + self._log.info("Server connection confirmed (first activity)") + if self.on_connected: + self.on_connected() + + def _probe_connection(self) -> bool: + """发送探测包测试服务端是否在线""" + if self._dealer_socket is None: + return False + try: + cmd = SyncCommand(request_id=uuid.uuid4().hex, topic="__probe__") + payload = self._serializer.encode_command(cmd) + self._dealer_socket.send(payload, flags=zmq.NOBLOCK) + return True + except (zmq.ZMQError, zmq.Again): + return False + + def _handle_sub_message(self) -> None: + try: + msg = self._sub_socket.recv_multipart(zmq.NOBLOCK) + except zmq.Again: + return + if len(msg) < 2: + return + topic = msg[0].decode("utf-8", errors="replace") + try: + event = self._serializer.decode_event(msg[1]) + except Exception: + self._log.warning("Failed to decode event for topic: %s", topic, exc_info=True) + return + self._notify_subscribers(topic, event) + + def _handle_dealer_message(self) -> None: + try: + msg = self._dealer_socket.recv_multipart(zmq.NOBLOCK) + except zmq.Again: + return + if len(msg) < 2: + return + payload = msg[1] if msg[0] == b"" else msg[-1] + try: + resp = self._serializer.decode_response(payload) + except Exception: + self._log.warning("Failed to decode response", exc_info=True) + return + + if resp.request_id and resp.data and resp.success: + topic = self._sync_topics.pop(resp.request_id, None) + if topic: + try: + snapshot = self._serializer.decode_event(resp.data) + self._notify_subscribers(topic, snapshot) + except Exception: + self._log.warning("Failed to decode snapshot", exc_info=True) + else: + self._sync_topics.pop(resp.request_id, None) + + self._resolve_pending(resp) + + def _notify_subscribers(self, topic: str, event: BaseEvent) -> None: + for cb in self._subscribers.get(topic, []): + try: + cb(event) + except Exception: + self._log.error("Subscriber callback error", exc_info=True) + + def _resolve_pending(self, resp: CommandResponse) -> None: + with self._pending_lock: + future = self._pending_requests.pop(resp.request_id, None) + if future: + future.set_result(resp) + + # ── 重连 ── + + def _handle_reconnect(self, backoff: float) -> None: + was_connected = self._is_connected + if was_connected: + self._is_connected = False + self._reconnect_count += 1 + if self.on_disconnected: + self.on_disconnected() + + self._stopped.wait(timeout=backoff) + if self._stopped.is_set(): + return + + self._close_sockets() + try: + self._create_sockets() + except zmq.ZMQError: + self._log.warning("Reconnect failed", exc_info=True) + # 重连失败且之前是已连接状态,保持断开 + return + + for topic in self._subscribers: + self._request_snapshot(topic) + + # 发送探测包,等待 _on_first_activity 确认 + self._probe_connection() + + # 注意:不在这里设置 _is_connected = True / 调用 on_connected() + # 必须等到真正收到服务端响应后才确认 + + @property + def is_connected(self) -> bool: + """当前连接状态""" + return self._is_connected + + @property + def reconnect_count(self) -> int: + """重连次数""" + return self._reconnect_count + + @property + def endpoint(self) -> str: + """当前端点地址""" + return self._endpoint + + @property + def pub_endpoint(self) -> str: + """PUB 端点地址""" + return self._pub_endpoint + + @property + def ctrl_endpoint(self) -> str: + """CTRL 端点地址""" + return self._ctrl_endpoint diff --git a/src/lib_zmq_plugins/log.py b/src/lib_zmq_plugins/log.py new file mode 100644 index 0000000..dc1f7bc --- /dev/null +++ b/src/lib_zmq_plugins/log.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class LogHandler(Protocol): + """日志处理器协议,使用者自行实现并传入 Server/Client + + 参考 loguru 的日志级别:DEBUG < INFO < WARNING < ERROR + + 示例 - 对接 loguru:: + + from loguru import logger as loguru_logger + + class LoguruHandler: + def debug(self, msg: str, /, **kwargs: object) -> None: + loguru_logger.debug(msg) + def info(self, msg: str, /, **kwargs: object) -> None: + loguru_logger.info(msg) + def warning(self, msg: str, /, **kwargs: object) -> None: + loguru_logger.warning(msg) + def error(self, msg: str, /, **kwargs: object) -> None: + loguru_logger.error(msg, **kwargs) + + 示例 - 对接标准库 logging:: + + import logging + _log = logging.getLogger("zmq_plugins") + + class StdlibHandler: + def debug(self, msg: str, /, **kwargs: object) -> None: + _log.debug(msg, **kwargs) + def info(self, msg: str, /, **kwargs: object) -> None: + _log.info(msg) + def warning(self, msg: str, /, **kwargs: object) -> None: + _log.warning(msg) + def error(self, msg: str, /, **kwargs: object) -> None: + _log.error(msg, **kwargs) + """ + + def debug(self, msg: str, /, *args: object, **kwargs: object) -> None: ... + def info(self, msg: str, /, *args: object, **kwargs: object) -> None: ... + def warning(self, msg: str, /, *args: object, **kwargs: object) -> None: ... + def error(self, msg: str, /, *args: object, **kwargs: object) -> None: ... + + +class NullHandler: + """默认日志处理器,所有日志丢弃""" + + def debug(self, msg: str, /, *args: object, **kwargs: object) -> None: ... + def info(self, msg: str, /, *args: object, **kwargs: object) -> None: ... + def warning(self, msg: str, /, *args: object, **kwargs: object) -> None: ... + def error(self, msg: str, /, *args: object, **kwargs: object) -> None: ... diff --git a/src/lib_zmq_plugins/serializer.py b/src/lib_zmq_plugins/serializer.py new file mode 100644 index 0000000..e66a8ab --- /dev/null +++ b/src/lib_zmq_plugins/serializer.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Union + +import enum +import msgspec + +from lib_zmq_plugins.shared.base import CommandResponse + +if TYPE_CHECKING: + from lib_zmq_plugins.shared.base import BaseCommand, BaseEvent + + +def _enc_hook(obj: object) -> Any: + """编码 hook:将 Enum 转为其原始值""" + if isinstance(obj, enum.Enum): + return obj.value + raise NotImplementedError(f"Cannot encode {type(obj).__name__}") + + +def _dec_hook(type_hint: type, obj: object) -> object: + """解码 hook:将原始值还原为 Enum""" + if isinstance(type_hint, type) and issubclass(type_hint, enum.Enum): + return type_hint(obj) + raise NotImplementedError(f"Cannot decode to {type_hint.__name__}") + + +def _make_union(types: list[type]) -> type: + """将类型列表转为 Union 类型,供 msgspec 多态反序列化使用""" + if len(types) == 1: + return types[0] + return Union[tuple(types)] + + +class Serializer: + """序列化器,持有上层注册的类型信息""" + + def __init__(self) -> None: + self._event_types: list[type[BaseEvent]] = [] + self._command_types: list[type[BaseCommand]] = [] + self._event_union: type = object + self._command_union: type = object + + def register_event_types(self, *types: type[BaseEvent]) -> None: + self._event_types.extend(types) + self._event_union = _make_union(self._event_types) + + def register_command_types(self, *types: type[BaseCommand]) -> None: + self._command_types.extend(types) + self._command_union = _make_union(self._command_types) + + def encode_event(self, event: BaseEvent) -> bytes: + return msgspec.msgpack.encode(event, enc_hook=_enc_hook) + + def decode_event(self, data: bytes) -> BaseEvent: + if not self._event_types: + raise ValueError("No event types registered") + return msgspec.msgpack.decode( + data, type=self._event_union, dec_hook=_dec_hook + ) + + def encode_command(self, cmd: BaseCommand) -> bytes: + return msgspec.msgpack.encode(cmd, enc_hook=_enc_hook) + + def decode_command(self, data: bytes) -> BaseCommand: + if not self._command_types: + raise ValueError("No command types registered") + return msgspec.msgpack.decode( + data, type=self._command_union, dec_hook=_dec_hook + ) + + def encode_response(self, resp: CommandResponse) -> bytes: + return msgspec.msgpack.encode(resp, enc_hook=_enc_hook) + + def decode_response(self, data: bytes) -> CommandResponse: + return msgspec.msgpack.decode( + data, type=CommandResponse, dec_hook=_dec_hook + ) diff --git a/src/lib_zmq_plugins/server/__init__.py b/src/lib_zmq_plugins/server/__init__.py new file mode 100644 index 0000000..e8488b9 --- /dev/null +++ b/src/lib_zmq_plugins/server/__init__.py @@ -0,0 +1,3 @@ +from lib_zmq_plugins.server.zmq_server import ZMQServer + +__all__ = ["ZMQServer"] diff --git a/src/lib_zmq_plugins/server/zmq_server.py b/src/lib_zmq_plugins/server/zmq_server.py new file mode 100644 index 0000000..e201fde --- /dev/null +++ b/src/lib_zmq_plugins/server/zmq_server.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import threading +import time +from typing import Any, Callable +from urllib.parse import urlparse + +import zmq + +from lib_zmq_plugins.log import LogHandler, NullHandler +from lib_zmq_plugins.serializer import Serializer +from lib_zmq_plugins.shared.base import ( + BaseCommand, + BaseEvent, + CommandResponse, + SyncCommand, + get_event_tag, +) + + +def _derive_endpoints(base: str) -> tuple[str, str]: + """根据基础 endpoint 派生 PUB 和 CTRL 地址""" + parsed = urlparse(base) + if parsed.scheme == "ipc": + path = parsed.path + return ( + f"ipc://{path}_pub", + f"ipc://{path}_ctrl", + ) + elif parsed.scheme == "tcp": + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 5555 + return ( + f"tcp://{host}:{port + 1}", + f"tcp://{host}:{port + 2}", + ) + else: + raise ValueError(f"Unsupported scheme: {parsed.scheme}") + + +class ZMQServer: + """ZMQ 服务端,运行在游戏主进程中""" + + def __init__(self, endpoint: str, log_handler: LogHandler | None = None) -> None: + self._endpoint = endpoint + self._pub_endpoint, self._ctrl_endpoint = _derive_endpoints(endpoint) + self._serializer = Serializer() + self._serializer.register_command_types(SyncCommand) + self._handlers: dict[str, Callable[[BaseCommand], CommandResponse | None]] = {} + self._snapshot_providers: dict[str, Callable[[], BaseEvent]] = {} + self._log: LogHandler = log_handler or NullHandler() + + self._ctx: zmq.Context | None = None + self._pub_socket: zmq.Socket | None = None + self._router_socket: zmq.Socket | None = None + self._poller: zmq.Poller | None = None + self._thread: threading.Thread | None = None + self._stopped = threading.Event() + + # ── 类型注册 ── + + def register_event_types(self, *types: type[BaseEvent]) -> None: + self._serializer.register_event_types(*types) + + def register_command_types(self, *types: type[BaseCommand]) -> None: + self._serializer.register_command_types(*types) + + # ── Handler / Snapshot 注册 ── + + def register_handler( + self, + command_type: type[BaseCommand], + handler: Callable[[BaseCommand], CommandResponse | None], + ) -> None: + tag = command_type.__struct_config__.tag + if isinstance(tag, type): + tag = tag.__name__ + self._handlers[str(tag)] = handler + + def register_snapshot_provider( + self, topic: type[BaseEvent], provider: Callable[[], BaseEvent] + ) -> None: + self._snapshot_providers[get_event_tag(topic)] = provider + + # ── 生命周期 ── + + def start(self) -> None: + self._stopped.clear() + self._ctx = zmq.Context() + self._pub_socket = self._ctx.socket(zmq.PUB) + self._router_socket = self._ctx.socket(zmq.ROUTER) + + # 启用 ZMQ 原生心跳,与客户端匹配 + # HEARTBEAT_IVL: 每 5 秒发送心跳 + # HEARTBEAT_TIMEOUT: 5 秒内没收到回复视为断连 + # HEARTBEAT_TTL: 心跳包存活时间 + self._router_socket.setsockopt(zmq.HEARTBEAT_IVL, 5000) + self._router_socket.setsockopt(zmq.HEARTBEAT_TIMEOUT, 5000) + self._router_socket.setsockopt(zmq.HEARTBEAT_TTL, 10000) + + self._poller = zmq.Poller() + + self._pub_socket.bind(self._pub_endpoint) + self._router_socket.bind(self._ctrl_endpoint) + + self._poller.register(self._router_socket, zmq.POLLIN) + + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + + self._log.info("Server started: pub=%s, ctrl=%s", self._pub_endpoint, self._ctrl_endpoint) + + def stop(self) -> None: + self._stopped.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=3.0) + if self._pub_socket: + self._pub_socket.close(linger=0) + if self._router_socket: + self._router_socket.close(linger=0) + if self._ctx: + self._ctx.term() + self._pub_socket = None + self._router_socket = None + self._ctx = None + self._poller = None + self._thread = None + + # ── 事件发布(可在任意线程调用) ── + + def publish(self, topic: type[BaseEvent], event: BaseEvent) -> None: + if self._pub_socket is None: + raise RuntimeError("Server is not started") + tag = get_event_tag(topic) + event.timestamp = time.time() + payload = self._serializer.encode_event(event) + self._pub_socket.send_multipart([tag.encode("utf-8"), payload]) + + # ── 后台轮询 ── + + def _poll_loop(self) -> None: + while not self._stopped.is_set(): + try: + events = self._poller.poll(timeout=100) + except zmq.ZMQError: + break + + for socket, _ in events: + if socket is self._router_socket: + try: + msg = self._router_socket.recv_multipart(zmq.NOBLOCK) + except zmq.Again: + continue + if len(msg) < 2: + continue + client_id = msg[0] + payload = msg[1] + try: + cmd = self._serializer.decode_command(payload) + except Exception: + self._log.warning("Failed to decode command", exc_info=True) + continue + self._dispatch(client_id, cmd) + + def _dispatch(self, client_id: bytes, cmd: BaseCommand) -> None: + tag = cmd.__struct_config__.tag + if isinstance(tag, type): + tag = tag.__name__ + tag = str(tag) + + if tag == "__sync__": + self._handle_sync(client_id, cmd) + return + + handler = self._handlers.get(tag) + if handler is None: + self._log.warning("No handler for command: %s", tag) + return + + try: + result = handler(cmd) + except Exception as e: + self._log.error("Handler error for %s: %s", tag, e, exc_info=True) + if cmd.request_id: + resp = CommandResponse( + request_id=cmd.request_id, success=False, error=str(e) + ) + self._send_to_client(client_id, resp) + return + + if result is not None and cmd.request_id: + self._send_to_client(client_id, result) + + def _handle_sync(self, client_id: bytes, cmd: SyncCommand) -> None: + topic = cmd.topic + provider = self._snapshot_providers.get(topic) + if provider is None: + resp = CommandResponse( + request_id=cmd.request_id, + success=False, + error=f"No snapshot provider for topic: {topic}", + ) + else: + try: + snapshot = provider() + payload = self._serializer.encode_event(snapshot) + resp = CommandResponse( + request_id=cmd.request_id, success=True, data=payload + ) + except Exception as e: + self._log.error("Snapshot provider error for %s: %s", topic, e, exc_info=True) + resp = CommandResponse( + request_id=cmd.request_id, success=False, error=str(e) + ) + self._send_to_client(client_id, resp) + + def _send_to_client(self, client_id: bytes, resp: CommandResponse) -> None: + if self._router_socket is None: + return + try: + self._router_socket.send_multipart( + [client_id, b"", self._serializer.encode_response(resp)] + ) + except zmq.ZMQError: + self._log.warning("Failed to send response to client", exc_info=True) diff --git a/src/lib_zmq_plugins/shared/__init__.py b/src/lib_zmq_plugins/shared/__init__.py new file mode 100644 index 0000000..fefa7da --- /dev/null +++ b/src/lib_zmq_plugins/shared/__init__.py @@ -0,0 +1,3 @@ +from lib_zmq_plugins.shared.base import BaseCommand, BaseEvent, CommandResponse, SyncCommand, get_event_tag + +__all__ = ["BaseEvent", "BaseCommand", "CommandResponse", "SyncCommand", "get_event_tag"] diff --git a/src/lib_zmq_plugins/shared/base.py b/src/lib_zmq_plugins/shared/base.py new file mode 100644 index 0000000..b9c566e --- /dev/null +++ b/src/lib_zmq_plugins/shared/base.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import msgspec + + +class BaseEvent(msgspec.Struct, tag=True): + """事件推送基类(Server → Client) + + 上层继承时使用 tag 指定类型名: + class BoardUpdate(BaseEvent, tag="board_update"): + cells: list[CellData] + """ + timestamp: float = 0.0 + + +class BaseCommand(msgspec.Struct, tag=True): + """控制指令基类(Client → Server) + + 上层继承时使用 tag 指定类型名: + class ClickCommand(BaseCommand, tag="click"): + row: int + col: int + + 如需同步响应,Server handler 返回 CommandResponse 后由框架自动回传。 + """ + request_id: str = "" + + +class CommandResponse(msgspec.Struct, tag=True): + """控制指令响应(Server → Client)""" + request_id: str + success: bool + error: str | None = None + data: bytes | None = None + + +class SyncCommand(BaseCommand, tag="__sync__"): + """快照同步请求(库内部使用,Client 订阅时自动发送) + + Client 订阅某 topic 后,通过控制通道发送此请求给 Server, + Server 调用对应 topic 的 snapshot_provider 获取当前完整状态并返回。 + 用于解决 ZMQ PUB/SUB 的 Slow Joiner 问题。 + """ + topic: str = "" + + +def get_event_tag(event_type: type[BaseEvent]) -> str: + """从事件类提取 tag 字符串,用作 ZMQ 订阅 topic""" + tag = event_type.__struct_config__.tag + if isinstance(tag, type): + tag = tag.__name__ + return str(tag) diff --git a/src/main.py b/src/main.py index 5eb94a4..e362d28 100644 --- a/src/main.py +++ b/src/main.py @@ -12,15 +12,15 @@ import ms_toollib as ms import ctypes # from ctypes import wintypes -from mp_plugins.context import AppContext -from mp_plugins.events import * -from mp_plugins import PluginManager from pathlib import Path from utils import get_paths, patch_env -os.environ["QT_FONT_DPI"] = "96" - +# 插件系统(新) +from plugin_manager import GameServerBridge +from plugin_manager.app_paths import get_env_for_subprocess +import subprocess +os.environ["QT_FONT_DPI"] = "96" def on_new_connection(localServer: QLocalServer): @@ -150,19 +150,53 @@ def cli_check_file(file_path: str) -> int: localServer.newConnection.connect( lambda: on_new_connection(localServer=localServer) ) - env = patch_env() - context = AppContext(name="Metasweeper", version="1.0.0", display_name="元扫雷", - plugin_dir=(Path(get_paths()) / - "plugins").as_posix(), - app_dir=get_paths() - ) - PluginManager.instance().context = context - - PluginManager.instance().start(Path(get_paths()) / "plugins", env) mainWindow = mainWindowGUI.MainWindow() ui = mineSweeperGUI.MineSweeperGUI(mainWindow, sys.argv) ui.mainWindow.show() - # ui.mainWindow.game_setting = ui.game_setting + + # ── 启动 ZMQ Server + 插件管理器 ── + game_server = GameServerBridge(ui) + + # 打包后直接调用 plugin_manager.exe,开发模式用 python -m + if getattr(sys, 'frozen', False): + base_dir = os.path.dirname(sys.executable) + plugin_exe = os.path.join(base_dir, "plugin_manager.exe") + if not os.path.exists(plugin_exe): + QtWidgets.QMessageBox.warning( + mainWindow, "Plugin Manager", + f"plugin_manager.exe not found:\n{plugin_exe}\n\nPlugins will be disabled.", + ) + plugin_process = None + else: + cmd = [plugin_exe, "--mode", "tray"] + cwd = base_dir + try: + plugin_process = subprocess.Popen( + cmd, cwd=cwd, env=get_env_for_subprocess(), + ) + except Exception as e: + QtWidgets.QMessageBox.warning( + mainWindow, "Plugin Manager", + f"Failed to start plugin_manager:\n{e}", + ) + plugin_process = None + else: + cmd = [sys.executable, "-m", "plugin_manager", "--mode", "tray"] + cwd = os.path.dirname(os.path.abspath(__file__)) + try: + plugin_process = subprocess.Popen( + cmd, cwd=cwd, env=get_env_for_subprocess(), + ) + except Exception as e: + print(f"[WARN] Failed to start plugin_manager: {e}") + plugin_process = None + + ui._plugin_process = plugin_process # 保存引用,防止被 GC + + # 连接信号:插件发来的新游戏指令 → 主线程处理 + game_server.signals.new_game_requested.connect(lambda r, c, m: None) # TODO: 接入游戏逻辑 + + game_server.start() # _translate = QtCore.QCoreApplication.translate hwnd = int(ui.mainWindow.winId()) @@ -170,11 +204,18 @@ def cli_check_file(file_path: str) -> int: SetWindowDisplayAffinity = ctypes.windll.user32.SetWindowDisplayAffinity ui.disable_screenshot = lambda: ... if SetWindowDisplayAffinity( hwnd, 0x00000011) else 1/0 - ui.enable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000000) else 1/0 - app.aboutToQuit.connect(PluginManager.instance().stop) + ui.enable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000000) else 1 / 0 + ) + + def _cleanup(): + game_server.stop() + if plugin_process is not None and plugin_process.poll() is None: + plugin_process.terminate() + plugin_process.wait(timeout=5) + + app.aboutToQuit.connect(_cleanup) sys.exit(app.exec_()) - ... # except: # pass diff --git a/src/mineSweeperGUI.py b/src/mineSweeperGUI.py index 7efa233..7e93521 100644 --- a/src/mineSweeperGUI.py +++ b/src/mineSweeperGUI.py @@ -32,9 +32,6 @@ from mainWindowGUI import MainWindow from datetime import datetime from mineSweeperVideoPlayer import MineSweeperVideoPlayer -from pluginDialog import PluginManagerUI -from mp_plugins import PluginManager, PluginContext -from mp_plugins.events import GameEndEvent class MineSweeperGUI(MineSweeperVideoPlayer): @@ -560,16 +557,6 @@ def gameFinished(self): status = utils.GameBoardState(ms_board.game_board_state) if status == utils.GameBoardState.Win: self.dump_evf_file_data() - event = GameEndEvent() - data = msgspec.structs.asdict(event) - 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] = getattr(ms_board, key) - event = GameEndEvent(**data) - PluginManager.instance().send_event(event, response_count=0) def gameWin(self): # 成功后改脸和状态变量,停时间 self.timer_10ms.stop() @@ -1425,5 +1412,4 @@ def closeEvent_(self): self.record_setting.sync() def action_OpenPluginDialog(self): - dialog = PluginManagerUI(PluginManager.instance().Get_Plugin_Names()) - dialog.exec() + pass diff --git a/src/mp_plugins/__init__.py b/src/mp_plugins/__init__.py deleted file mode 100644 index 80e2a28..0000000 --- a/src/mp_plugins/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .plugin_process import PluginProcess -from .plugin_manager import PluginManager -from .base import BaseContext, BaseEvent, BasePlugin, BaseConfig -from .base.context import PluginContext - -__all__ = [ - "PluginManager", - "BaseContext", - "BaseEvent", - "BasePlugin", - "BaseConfig", - "PluginContext", -] diff --git a/src/mp_plugins/base/__init__.py b/src/mp_plugins/base/__init__.py deleted file mode 100644 index 50f7b84..0000000 --- a/src/mp_plugins/base/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -from .context import BaseContext, PluginContext -from .error import Error -from .mode import PluginStatus, MessageMode, ValueEnum -from .event import BaseEvent -from .plugin import BasePlugin -from .message import Message -from ._data import get_subclass_by_name -from .config import ( - BaseConfig, - BaseSetting, - BoolSetting, - NumberSetting, - SelectSetting, - TextSetting, -) - -__all__ = [ - "BaseContext", - "PluginContext", - "Error", - "PluginStatus", - "MessageMode", - "BaseEvent", - "BasePlugin", - "Message", - "ValueEnum", - "BaseConfig", - "BaseSetting", - "BoolSetting", - "NumberSetting", - "SelectSetting", - "TextSetting", -] diff --git a/src/mp_plugins/base/_data.py b/src/mp_plugins/base/_data.py deleted file mode 100644 index a3386c8..0000000 --- a/src/mp_plugins/base/_data.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import Type -from msgspec import Struct, json - - -class _BaseData(Struct): - """ - 数据基类 - """ - - def copy(self): - new_data = json.decode(json.encode(self), type=type(self)) - return new_data - - -_subclass_cache = {} - - -def get_subclass_by_name(name: str) -> Type[_BaseData] | None: - """ - 根据类名获取 BaseEvent 的派生类(支持多级继承),带缓存 - """ - global _subclass_cache - if name in _subclass_cache: - return _subclass_cache[name] - - def _iter_subclasses(cls): - for sub in cls.__subclasses__(): - yield sub - yield from _iter_subclasses(sub) - - for subcls in _iter_subclasses(_BaseData): - _subclass_cache[subcls.__name__] = subcls - if subcls.__name__ == name: - return subcls - - return None diff --git a/src/mp_plugins/base/config.py b/src/mp_plugins/base/config.py deleted file mode 100644 index 0639a4a..0000000 --- a/src/mp_plugins/base/config.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Any, Dict, List, Sequence -from ._data import _BaseData, get_subclass_by_name -import msgspec - - -class BaseSetting(_BaseData): - name: str = "" - value: Any = None - setting_type: str = "BaseSetting" - - -class TextSetting(BaseSetting): - value: str = "" - placeholder: str = "" - setting_type: str = "TextSetting" - - -class NumberSetting(BaseSetting): - value: float = 0.0 - min_value: float = 0.0 - max_value: float = 100.0 - step: float = 1.0 - setting_type: str = "NumberSetting" - - -class BoolSetting(BaseSetting): - value: bool = False - description: str = "" - setting_type: str = "BoolSetting" - - -class SelectSetting(BaseSetting): - value: str = "" - options: List[str] = [] - setting_type: str = "SelectSetting" - - -class BaseConfig(_BaseData): - """ """ - pass - - -def Get_settings(data: Dict[str, Dict[str, Any]]) -> Dict[str, BaseSetting]: - settings = {} - for key, value in data.items(): - if settings_type := value.get("setting_type"): - setting: BaseSetting = msgspec.json.decode( - msgspec.json.encode(value), type=get_subclass_by_name(settings_type) - ) - settings[key] = setting - return settings diff --git a/src/mp_plugins/base/context.py b/src/mp_plugins/base/context.py deleted file mode 100644 index b8e8823..0000000 --- a/src/mp_plugins/base/context.py +++ /dev/null @@ -1,33 +0,0 @@ -from ._data import _BaseData -from .mode import PluginStatus -from datetime import datetime -from typing import List - - -class BaseContext(_BaseData): - """ - 上下文基类 - """ - - name: str = "" - version: str = "" - plugin_dir: str = "" - app_dir: str = "" - - -class PluginContext(BaseContext): - """ - 插件上下文 - """ - - pid: int = 0 - name: str = "" - display_name: str = "" - description: str = "" - version: str = "" - author: str = "" - author_email: str = "" - url: str = "" - status: PluginStatus = PluginStatus.Stopped - heartbeat: float = datetime.now().timestamp() - subscribers: List[str] = [] diff --git a/src/mp_plugins/base/error.py b/src/mp_plugins/base/error.py deleted file mode 100644 index 1e94acb..0000000 --- a/src/mp_plugins/base/error.py +++ /dev/null @@ -1,10 +0,0 @@ -from ._data import _BaseData - - -class Error(_BaseData): - """ - 错误信息 - """ - - type: str - message: str diff --git a/src/mp_plugins/base/event.py b/src/mp_plugins/base/event.py deleted file mode 100644 index 55565df..0000000 --- a/src/mp_plugins/base/event.py +++ /dev/null @@ -1,11 +0,0 @@ -from ._data import _BaseData -from datetime import datetime -import uuid - - -class BaseEvent(_BaseData): - """ - 事件基类 - """ - - timestamp: float = datetime.now().timestamp() diff --git a/src/mp_plugins/base/message.py b/src/mp_plugins/base/message.py deleted file mode 100644 index 43daa5c..0000000 --- a/src/mp_plugins/base/message.py +++ /dev/null @@ -1,32 +0,0 @@ -import uuid -from msgspec import Struct, json -from typing import Any, Union, Type, TypeVar, Generic, Optional -from ._data import _BaseData, get_subclass_by_name -from datetime import datetime -from .mode import MessageMode - -BaseData = TypeVar("BaseData", bound=_BaseData) - - -class Message(_BaseData): - """ - 一个消息类用于包装事件及消息的基本信息 - """ - - id = str(uuid.uuid4()) - data: Any = None - timestamp: datetime = datetime.now() - mode: MessageMode = MessageMode.Unknown - Source: str = "main" # 来源,也就是消息的发送者 - class_name: str = "" - - def copy(self): - new_message = json.decode(json.encode(self), type=Message) - return new_message - - def __post_init__(self): - cls = get_subclass_by_name(self.class_name) - if cls: - # 将原始 dict 解析成对应的 Struct - if isinstance(self.data, dict): - self.data = cls(**self.data) diff --git a/src/mp_plugins/base/mode.py b/src/mp_plugins/base/mode.py deleted file mode 100644 index d3819d4..0000000 --- a/src/mp_plugins/base/mode.py +++ /dev/null @@ -1,29 +0,0 @@ -from enum import Enum - - -class ValueEnum(Enum): - def __eq__(self, value: object) -> bool: - if isinstance(value, Enum): - return self.value == value.value - return self.value == value # 支持直接比较 value - - def __str__(self) -> str: - return self.name - - -class PluginStatus(ValueEnum): - """ - 插件状态 - """ - - Running = "running" - Stopped = "stopped" - Dead = "dead" - - -class MessageMode(ValueEnum): - Event = "event" - Context = "context" - Error = "error" - Unknown = "unknown" - Heartbeat = "heartbeat" diff --git a/src/mp_plugins/base/plugin.py b/src/mp_plugins/base/plugin.py deleted file mode 100644 index cf314ff..0000000 --- a/src/mp_plugins/base/plugin.py +++ /dev/null @@ -1,231 +0,0 @@ -import time -from abc import ABC, abstractmethod -import ctypes -import inspect -from pathlib import Path -from msgspec import json -from datetime import datetime, timedelta -from .error import Error -import os -from typing import ( - Any, - Callable, - Dict, - List, - Optional, - Type, - Union, - TypeVar, - Generic, - ParamSpec, -) -from .message import Message, BaseData, MessageMode -from ._data import _BaseData, get_subclass_by_name -from .event import BaseEvent -from .context import BaseContext, PluginContext -from queue import Queue -import zmq -from .mode import PluginStatus -from .config import BaseConfig, BaseSetting - - -P = ParamSpec("P") -R = TypeVar("R") - -# 引入 Windows 多媒体计时器函数 -timeBeginPeriod = ctypes.windll.winmm.timeBeginPeriod -timeEndPeriod = ctypes.windll.winmm.timeEndPeriod - - -class BasePlugin(ABC): - """ - 插件基类 - """ - - _context: BaseContext - _plugin_context: PluginContext = PluginContext() - _config: BaseConfig - - @abstractmethod - def build_plugin_context(self) -> None: ... - - @staticmethod - def event_handler( - event: Type[BaseEvent], - ) -> Callable[[Callable[P, BaseEvent]], Callable[P, BaseEvent]]: - """ - 装饰器:标记方法为事件 handler - """ - - def decorator(func: Callable[P, BaseEvent]) -> Callable[P, BaseEvent]: - func.__event_handler__ = event - return func - - return decorator - - def __init_subclass__(cls) -> None: - super().__init_subclass__() - cls._event_handlers: Dict[ - Type[BaseEvent], List[Callable[[BasePlugin, BaseEvent], BaseEvent]] - ] = {} - event_set: set[str] = set() - for _, value in cls.__dict__.items(): - if hasattr(value, "__event_handler__"): - if value.__event_handler__ not in cls._event_handlers: - cls._event_handlers[value.__event_handler__] = [] - event_set.add(value.__event_handler__.__name__) - cls._event_handlers[value.__event_handler__].append(value) - cls._plugin_context.subscribers = list(event_set) - cls._plugin_context.pid = os.getpid() - - def __init__(self) -> None: - super().__init__() - context = zmq.Context(5) - self.dealer = context.socket(zmq.DEALER) - self.__message_queue: Queue[Message] = Queue() - self.__heartbeat_time = datetime.now() - self._is_running: bool = False - self.build_plugin_context() - - @abstractmethod - def initialize(self) -> None: - self.init_config(self._config) - - @abstractmethod - def shutdown(self) -> None: - self._plugin_context.status = PluginStatus.Stopped - self.refresh_context() - - def run(self, host: str, port: int) -> None: - self.dealer.setsockopt_string( - zmq.IDENTITY, str(self._plugin_context.pid)) - self.dealer.connect(f"tcp://{host}:{port}") - self._is_running = True - self._plugin_context.status = PluginStatus.Running - self.refresh_context() - poller = zmq.Poller() - poller.register(self.dealer, zmq.POLLIN) - timeBeginPeriod(1) - while self._is_running: - # 轮询是否有数据,超时 1ms - events = dict(poller.poll(timeout=0)) - - # ---- 接收消息 ----------------------------------------------------- - if self.dealer in events: - data = self.dealer.recv() - message = json.decode(data, type=Message) - self.__message_dispatching(message) - - # ---- 发送消息 ----------------------------------------------------- - if not self.__message_queue.empty(): - msg = self.__message_queue.get() - self.dealer.send(json.encode(msg)) - - # ---- 心跳检查 ----------------------------------------------------- - if datetime.now() - self.__heartbeat_time > timedelta(seconds=10): - break - - time.sleep(0.001) - - # --------------------------------------------------------------------- - timeEndPeriod(1) - self.shutdown() - self.dealer.close() - - # ------------------------------------------------------------------------- - - def __message_dispatching(self, message: Message) -> None: - """ - 消息分发 - """ - message.Source = self.__class__.__name__ - self.__heartbeat_time = datetime.now() - - if message.mode == MessageMode.Event: - if isinstance(message.data, BaseEvent) and message.data is not None: - if message.data.__class__ not in self._event_handlers: - self.send_error( - type="Event Subscribe", - error=f"{self.__class__.__name__} not Subscribe {message.data.__class__.__name__}", - ) - return - for handler in self._event_handlers[message.data.__class__]: - event = handler(self, message.data) - if event.__class__ != message.data.__class__: - raise TypeError( - f"Event handler must return the same event type got {event.__class__.__name__} expected {message.data.__class__.__name__}" - ) - message.data = event - self.__message_queue.put(message) - else: - self.send_error( - type="Event Validation", - error=f"{message.data} is not a valid event", - ) - elif message.mode == MessageMode.Context: - if isinstance(message.data, BaseContext) and message.data is not None: - if hasattr(self, "_context"): - self._context = message.data - self.initialize() - self._context = message.data - elif message.mode == MessageMode.Error: - pass - elif message.mode == MessageMode.Unknown: - pass - elif message.mode == MessageMode.Heartbeat: - self.__message_queue.put(message) - - def refresh_context(self): - self._plugin_context.heartbeat = datetime.now().timestamp() - self.__message_queue.put( - Message( - data=self._plugin_context, - mode=MessageMode.Context, - Source=self.__class__.__name__, - class_name=self._plugin_context.__class__.__name__, - ) - ) - - @property - def context(self): - return self._context - - def send_error(self, type: str, error: str): - self.__message_queue.put( - Message( - data=Error(type=type, message=error), - mode=MessageMode.Error, - Source=self.__class__.__name__, - ) - ) - - def init_config(self, config: BaseConfig): - self._config = config - old_config = self.config - config_path = self.context.plugin_dir + \ - f'/{self.__class__.__name__}/{self.__class__.__name__}.json' - if old_config is None: - with open(config_path, "w", encoding="utf-8") as f: - b = json.encode(config) - f.write(b.decode("utf-8")) - - @property - def config(self): - config_path = self.context.plugin_dir + \ - f'/{self.__class__.__name__}/{self.__class__.__name__}.json' - if os.path.exists(config_path): - try: - with open(config_path, "r", encoding="utf-8") as f: - config = json.decode(f.read(), type=self._config.__class__) - return config - except: - return None - - @property - def path(self) -> Path: - if hasattr(self, "_context"): - return Path(self._context.plugin_dir) / self.__class__.__name__ - return Path(os.path.dirname(os.path.abspath(__file__))) - - def stop(self): - self._is_running = False diff --git a/src/mp_plugins/context.py b/src/mp_plugins/context.py deleted file mode 100644 index 88ffd8b..0000000 --- a/src/mp_plugins/context.py +++ /dev/null @@ -1,5 +0,0 @@ -from .base import BaseContext - - -class AppContext(BaseContext): - display_name: str = "元扫雷" diff --git a/src/mp_plugins/events.py b/src/mp_plugins/events.py deleted file mode 100644 index 52d8041..0000000 --- a/src/mp_plugins/events.py +++ /dev/null @@ -1,43 +0,0 @@ -from .base import BaseEvent - - -class GameEndEvent(BaseEvent): - game_board_state: int = 0 - rtime: float = 0 - left: int = 126 - right: int = 11 - double: int = 14 - left_s: float = 2.5583756345177666 - right_s: float = 0.2233502538071066 - double_s: float = 0.28426395939086296 - level: int = 5 - cl: int = 151 - cl_s: float = 3.065989847715736 - ce: int = 144 - ce_s: float = 2.9238578680203045 - rce: int = 11 - lce: int = 119 - dce: int = 14 - bbbv: int = 127 - bbbv_solved: int = 127 - bbbv_s: float = 2.5786802030456855 - flag: int = 11 - path: float = 6082.352554578606 - etime: float = 1666124184868000 - start_time: int = 1666124135606000 - end_time: int = 1666124184868000 - mode: int = 0 - software: str = "Arbiter" - player_identifier: str = "Wang Jianing G01825" - race_identifier: str = "" - uniqueness_identifier: str = "" - stnb: float = 0 - corr: float = 0 - thrp: float = 0 - ioe: float = 0 - is_official: int = 0 - is_fair: int = 0 - op: int = 0 - isl: int = 0 - pluck: float = 0 - raw_data: str = '' diff --git a/src/mp_plugins/plugin_manager.py b/src/mp_plugins/plugin_manager.py deleted file mode 100644 index add6e5d..0000000 --- a/src/mp_plugins/plugin_manager.py +++ /dev/null @@ -1,315 +0,0 @@ -import asyncio -import ctypes -from datetime import datetime, timedelta -import sys -import threading -from typing import Callable, Dict, List, Optional, TypeVar, Generic, Sequence -from threading import RLock -import zmq -from queue import Queue -import time -import msgspec -import pathlib -import json - -from .base import ( - PluginContext, - BaseContext, - BaseEvent, - MessageMode, - Message, - Error, - PluginStatus, -) -from .plugin_process import PluginProcess -from .base.config import BaseConfig, BaseSetting, Get_settings - - -_Event = TypeVar("_Event", bound=BaseEvent) - -# 引入 Windows 多媒体计时器函数 -timeBeginPeriod = ctypes.windll.winmm.timeBeginPeriod -timeEndPeriod = ctypes.windll.winmm.timeEndPeriod - - -class PluginManager(object): - - __instance: Optional["PluginManager"] = None - __lock = RLock() - - def __init__(self) -> None: - context = zmq.Context(3) - - self.__router = context.socket(zmq.ROUTER) - self.__poller = zmq.Poller() - self.__poller.register(self.__router, zmq.POLLIN) - - self.__port = self.__router.bind_to_random_port("tcp://*") - - self.__plugins_context: Dict[str, PluginContext] = {} - self.__plugins_process: Dict[str, PluginProcess] = {} - self.__plugins_config_path: Dict[str, pathlib.Path] = {} - - self.__event_dict: Dict[str, List[BaseEvent]] = {} - self.__message_queue: Queue[Message] = Queue() - - self.__error_callback = None - - self.__running = False - self.__context: BaseContext | None = None - - # ------------------------- - # 单例 - # ------------------------- - @classmethod - def instance(cls) -> "PluginManager": - if cls.__instance is None: - with cls.__lock: - if cls.__instance is None: - cls.__instance = cls() - return cls.__instance - - # ------------------------- - # 公开属性 - # ------------------------- - @property - def port(self): - return self.__port - - @property - def context(self) -> BaseContext: - return self.__context # type: ignore - - @context.setter - def context(self, context: BaseContext): - self.__context = context - - @property - def plugin_contexts(self): - for context in self.__plugins_context.values(): - yield context.copy() - - # ------------------------- - # 启动 / 停止 - # ------------------------- - def start(self, plugin_dir: pathlib.Path, env: Dict[str, str]): - timeBeginPeriod(1) - if self.__running: - return - - self.__running = True - - plugin_dir.mkdir(parents=True, exist_ok=True) - self._load_plugins(plugin_dir, env) - - # 后台线程执行单线程循环 - self.__loop_thread = threading.Thread( - target=self.run_loop, daemon=True) - self.__loop_thread.start() - - print("Plugin manager started (non-blocking)") - - def stop(self): - timeEndPeriod(1) - self.__running = False - if hasattr(self, "__loop_thread"): - self.__loop_thread.join(2) - for process in self.__plugins_process.values(): - process.stop() - print("Plugin manager stopped") - - # ------------------------- - # 加载插件(保持原逻辑) - # ------------------------- - def _load_plugins(self, plugin_dir, env): - for plugin_path in plugin_dir.iterdir(): - if plugin_path.is_dir(): - if plugin_path.name.startswith("__"): - continue - - self.__plugins_config_path[plugin_path.name] = plugin_path / ( - plugin_path.name + ".json" - ) - - # exe 优先 - if (plugin_path / (plugin_path.name + ".exe")).exists(): - path = plugin_path / (plugin_path.name + ".exe") - else: - path = plugin_path / (plugin_path.name + ".py") - - process = PluginProcess(path) - process.start("localhost", self.__port, env=env) - - self.__plugins_process[str(process.pid)] = process - - # ------------------------- - # 单线程事件循环(核心) - # ------------------------- - def run_loop(self): - heartbeat_timer = time.time() - - while self.__running: - - # 1. 收消息 - events = dict(self.__poller.poll(0)) - if self.__router in events: - identity, data = self.__router.recv_multipart() - self._handle_incoming(identity, data) - - # 2. 发消息 - if not self.__message_queue.empty(): - message = self.__message_queue.get() - self._send_message_internal(message) - - # 3. 心跳 - now = time.time() - if now - heartbeat_timer > 5: - self._send_heartbeat() - heartbeat_timer = now - time.sleep(0.001) - - # ------------------------- - # 消息解包逻辑 - # ------------------------- - def _handle_incoming(self, identity, data): - pid = identity.decode() - - if pid in self.__plugins_context: - self.__plugins_context[pid].heartbeat = time.time() - - message = msgspec.json.decode(data, type=Message) - - # Event - if message.mode == MessageMode.Event: - if message.id in self.__event_dict and isinstance(message.data, BaseEvent): - self.__event_dict[message.id].append(message.data) - - # Error - elif message.mode == MessageMode.Error: - if self.__error_callback and isinstance(message.data, Error): - self.__error_callback(message.data) - - # 初次 Context - elif message.mode == MessageMode.Context: - if isinstance(message.data, PluginContext): - self.__plugins_context[pid] = message.data - # 把主 Context 返回插件 - self.__send_message( - Message( - data=self.context, - mode=MessageMode.Context, - class_name=self.context.__class__.__name__, - ) - ) - - # ------------------------- - # 事件发射 - # ------------------------- - def send_event( - self, event: _Event, timeout: int = 10, response_count: int = 1 - ) -> Sequence[_Event]: - - message = Message( - data=event, - mode=MessageMode.Event, - class_name=event.__class__.__name__, - ) - - self.__event_dict[message.id] = [] - - self.__send_message(message) - - expire = datetime.now() + timedelta(seconds=timeout) - - while datetime.now() < expire and ( - len(self.__event_dict.get(message.id, [])) < response_count - ): - time.sleep(0.0001) - - result = self.__event_dict.get(message.id, []) - if message.id in self.__event_dict: - del self.__event_dict[message.id] - - return result # type: ignore - - # ------------------------- - # 发消息入口(入队) - # ------------------------- - def __send_message(self, message: Message): - self.__message_queue.put(message) - - # ------------------------- - # 发消息逻辑(内部) - # ------------------------- - def _send_message_internal(self, message: Message): - if message.mode in ( - MessageMode.Event, - MessageMode.Context, - MessageMode.Heartbeat, - ): - for ctx in self.__plugins_context.values(): - if ctx.status == PluginStatus.Running: - if message.mode == MessageMode.Event: - if message.class_name not in ctx.subscribers: - continue - self.__router.send_multipart( - [ - str(ctx.pid).encode(), - msgspec.json.encode(message), - ] - ) - - # ------------------------- - # 心跳 - # ------------------------- - def _send_heartbeat(self): - msg = Message(mode=MessageMode.Heartbeat) - for ctx in self.__plugins_context.values(): - if ctx.status == PluginStatus.Running: - self.__router.send_multipart( - [str(ctx.pid).encode(), msgspec.json.encode(msg)] - ) - - # ------------------------- - # 错误回调 - # ------------------------- - def bind_error(self, func: Callable[[Error], None]): - self.__error_callback = func - - # ------------------------- - # 配置操作(原样) - # ------------------------- - def Get_Settings(self, plugin_name: str): - if plugin_name not in self.__plugins_config_path: - return {} - if not self.__plugins_config_path[plugin_name].exists(): - return {} - - data = json.loads( - self.__plugins_config_path[plugin_name].read_text("utf-8")) - return Get_settings(data) - - def Set_Settings(self, plugin_name: str, name: str, setting: BaseSetting): - data = self.Get_Settings(plugin_name) - - new_data = {} - data[name] = setting - for key, value in data.items(): - if isinstance(value, BaseSetting): - new_data[key] = msgspec.structs.asdict(value) - - self.__plugins_config_path[plugin_name].write_text( - json.dumps(new_data, indent=4) - ) - - # ------------------------- - # 查找插件 Context - # ------------------------- - def Get_Context_By_Name(self, plugin_name: str) -> Optional[PluginContext]: - for ctx in self.__plugins_context.values(): - if ctx.name == plugin_name: - return ctx.copy() - return None - - def Get_Plugin_Names(self) -> List[str]: - return [ctx.name for ctx in self.__plugins_context.values()] diff --git a/src/mp_plugins/plugin_process.py b/src/mp_plugins/plugin_process.py deleted file mode 100644 index 1d60917..0000000 --- a/src/mp_plugins/plugin_process.py +++ /dev/null @@ -1,60 +0,0 @@ -import sys -import subprocess -import os -from pathlib import Path -from typing import List, Optional, Dict, Any -import signal - -class PluginProcess(object): - - def __init__(self, plugin_path: Path) -> None: - self.__plugin_path = plugin_path - self._process: Optional[subprocess.Popen] = None - self.__pid: int - self.plugin_dir_name = plugin_path.parent.parent.name - self.plugin_name = plugin_path.name.split(".")[0] - - @property - def pid(self): - return self.__pid - - @property - def plugin_path(self): - return self.__plugin_path - - def start(self, host: str, port: int, env: Dict[str, str]): - if self._process is not None: - return - module = f"{self.plugin_dir_name}.{self.plugin_name}.{self.plugin_name}" - if self.__plugin_path.suffix == ".py": - self._process = subprocess.Popen( - [ - sys.executable, - str(self.__plugin_path), - host, - str(port), - ], - env=env, - ) - else: - self._process = subprocess.Popen( - [ - self.__plugin_path, - host, - str(port), - ], - ) - self.__pid = self._process.pid - - def stop(self): - if self._process is None: - return - if self._process.poll() is None: - if sys.platform == "win32": - self._process.kill() - else: - self._process.terminate() - try: - self._process.wait(timeout=2) - except subprocess.TimeoutExpired: - self._process.kill() diff --git a/src/pluginDialog.py b/src/pluginDialog.py deleted file mode 100644 index 3cd8bd1..0000000 --- a/src/pluginDialog.py +++ /dev/null @@ -1,253 +0,0 @@ -from PyQt5.QtWidgets import ( - QApplication, - QDialog, - QListWidget, - QFormLayout, - QLabel, - QPushButton, - QVBoxLayout, - QHBoxLayout, - QGroupBox, - QMessageBox, - QScrollArea, - QWidget, - QLineEdit, - QDoubleSpinBox, - QCheckBox, - QComboBox, -) -from PyQt5.QtCore import Qt, QTimer -from ui.uiComponents import RoundQDialog -import sys -from typing import Dict - -import msgspec -from mp_plugins import PluginContext - -# 你的 Setting 类 -from mp_plugins.base import ( - BaseSetting, - TextSetting, - NumberSetting, - BoolSetting, - SelectSetting, -) -from mp_plugins import PluginManager -from PyQt5.QtCore import QCoreApplication - -_translate = QCoreApplication.translate - - -class PluginManagerUI(QDialog): - - def __init__(self, plugin_names: list[str]): - """ - plugin_contexts: 你传入的 plugin 列表 - get_settings_func(plugin_ctx) -> Dict[str, BaseSetting] - 你自己的函数,用来根据 PluginContext 拿到 settings - """ - super().__init__() - - self.plugin_names = plugin_names - self.current_settings_widgets: Dict[str, QWidget] = {} - self.timer = QTimer(self) - self.timer.timeout.connect( - lambda: self.update_context(self.list_widget.currentRow()) - ) - self.timer.start(3000) # 每秒更新一次 - - self.setWindowTitle(_translate("Form", "插件管理")) - self.resize(1000, 650) - - root_layout = QHBoxLayout(self) - - # ================= 左侧插件列表 ================= - self.list_widget = QListWidget() - for name in self.plugin_names: - ctx = PluginManager.instance().Get_Context_By_Name(name) - if ctx is None: - continue - self.list_widget.addItem(ctx.display_name) - - self.list_widget.currentRowChanged.connect(self.on_plugin_selected) - root_layout.addWidget(self.list_widget, 1) - - # ================= 右侧 ================= - right_layout = QVBoxLayout() - - # -------- 插件详情 -------- - details_group = QGroupBox(_translate("Form", "插件详情")) - self.details_layout = QFormLayout() - pid_label = QLabel() - name_label = QLabel() - display_name_label = QLabel() - description_label = QLabel() - version_label = QLabel() - author_label = QLabel() - email_label = QLabel() - url_label = QLabel() - status_label = QLabel() - heartbeat_label = QLabel() - subscribed_events_label = QLabel() - pid_label.setObjectName("pid") - name_label.setObjectName("name") - display_name_label.setObjectName("display_name") - description_label.setObjectName("description") - version_label.setObjectName("version") - author_label.setObjectName("author") - email_label.setObjectName("author_email") - url_label.setObjectName("url") - status_label.setObjectName("status") - heartbeat_label.setObjectName("heartbeat") - subscribed_events_label.setObjectName("subscribers") - self.detail_labels = { - _translate("Form", "进程ID"): pid_label, - _translate("Form", "插件名称"): name_label, - _translate("Form", "插件显示名称"): display_name_label, - _translate("Form", "插件描述"): description_label, - _translate("Form", "插件版本"): version_label, - _translate("Form", "作者"): author_label, - _translate("Form", "作者邮箱"): email_label, - _translate("Form", "插件URL"): url_label, - _translate("Form", "插件状态"): status_label, - _translate("Form", "心跳时间"): heartbeat_label, - _translate("Form", "订阅事件"): subscribed_events_label - } - - for key, widget in self.detail_labels.items(): - self.details_layout.addRow( - key.replace("_", " ").title() + ":", widget) - - details_group.setLayout(self.details_layout) - right_layout.addWidget(details_group, 1) - - # -------- 设置面板(滚动) -------- - settings_group = QGroupBox(_translate("Form", "设置")) - vbox = QVBoxLayout() - - self.scroll_area = QScrollArea() - self.scroll_area.setWidgetResizable(True) - - self.scroll_content = QWidget() - self.scroll_layout = QFormLayout(self.scroll_content) - self.scroll_area.setWidget(self.scroll_content) - - vbox.addWidget(self.scroll_area, 1) - - # 保存按钮 - self.btn_save = QPushButton(_translate("Form", "保存")) - self.btn_save.clicked.connect(self.on_save_clicked) - vbox.addWidget(self.btn_save) - - settings_group.setLayout(vbox) - right_layout.addWidget(settings_group, 2) - - root_layout.addLayout(right_layout, 2) - - if self.plugin_names: - self.list_widget.setCurrentRow(0) - - # =================================================================== - # 左侧切换插件 - # =================================================================== - def on_plugin_selected(self, index: int): - if index < 0: - return - - self.update_context(index) - - # --- 你自己的获取 settings 的函数 --- - settings_dict = PluginManager.instance( - ).Get_Settings(self.plugin_names[index]) - - # --- 动态加载设置控件 --- - self.load_settings(settings_dict) - - def update_context(self, index: int): - if index < 0: - return - - ctx = PluginManager.instance().Get_Context_By_Name( - self.plugin_names[index]) - if ctx is None: - return - - # --- PluginContext 原样填充 --- - for key, value in msgspec.structs.asdict(ctx).items(): - label = self.findChild(QLabel, key) - if label is None: - continue - if isinstance(value, list): - value = ", ".join(value) - label.setText(str(value)) - - # =================================================================== - # 加载 settings - # =================================================================== - def load_settings(self, settings: Dict[str, BaseSetting]): - # 清空原控件 - while self.scroll_layout.count(): - item = self.scroll_layout.takeAt(0) - if item is None: - continue - widget = item.widget() - if widget is not None: - widget.deleteLater() - - self.current_settings_widgets.clear() - - for key, setting in settings.items(): - widget = None - - if isinstance(setting, TextSetting): - widget = QLineEdit() - widget.setText(setting.value) - - elif isinstance(setting, NumberSetting): - widget = QDoubleSpinBox() - widget.setRange(setting.min_value, setting.max_value) - widget.setSingleStep(setting.step) - widget.setValue(setting.value) - - elif isinstance(setting, BoolSetting): - widget = QCheckBox(setting.description) - widget.setChecked(setting.value) - - elif isinstance(setting, SelectSetting): - widget = QComboBox() - widget.addItems(setting.options) - widget.setCurrentText(setting.value) - - else: - continue - - self.current_settings_widgets[key] = widget - self.scroll_layout.addRow(setting.name + ":", widget) - - # =================================================================== - # 保存按钮 - # =================================================================== - def on_save_clicked(self): - ctx = PluginManager.instance().Get_Context_By_Name( - self.plugin_names[self.list_widget.currentRow()] - ) - if ctx is None: - return - - # --- 你自己的获取 settings 的函数 --- - settings_dict = PluginManager.instance().Get_Settings(ctx.name) - for key, widget in self.current_settings_widgets.items(): - setting = settings_dict[key] - if isinstance(widget, QLineEdit) and isinstance(setting, TextSetting): - setting.value = widget.text() - elif isinstance(widget, QDoubleSpinBox) and isinstance( - setting, NumberSetting - ): - setting.value = widget.value() - elif isinstance(widget, QCheckBox) and isinstance(setting, BoolSetting): - setting.value = widget.isChecked() - elif isinstance(widget, QComboBox) and isinstance(setting, SelectSetting): - setting.value = widget.currentText() - PluginManager.instance().Set_Settings(ctx.name, key, setting) - QMessageBox.information( - self, _translate("Form", "保存成功"), _translate("Form", "设置已保存")) diff --git a/src/plugin_manager/__init__.py b/src/plugin_manager/__init__.py new file mode 100644 index 0000000..39c3f4b --- /dev/null +++ b/src/plugin_manager/__init__.py @@ -0,0 +1,32 @@ +""" +插件管理器模块 + +基于 lib_zmq_plugins 实现的插件系统,支持: +- 插件同时具备后台数据处理和界面交互能力 +- 事件分发机制 +- 动态加载插件 +- 独立的主界面窗口 +""" + +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 + +__all__ = [ + "BasePlugin", + "PluginInfo", + "WindowMode", + "LogLevel", + "LogConfig", + "make_plugin_icon", + "PluginManager", + "PluginManagerWindow", + "EventDispatcher", + "PluginLoader", + "GameServerBridge", + "run_plugin_manager_process", +] \ No newline at end of file diff --git a/src/plugin_manager/__main__.py b/src/plugin_manager/__main__.py new file mode 100644 index 0000000..d836ced --- /dev/null +++ b/src/plugin_manager/__main__.py @@ -0,0 +1,58 @@ +""" +插件管理器命令行入口 + +用法: + 开发模式: python -m plugin_manager [--endpoint tcp://127.0.0.1:5555] [--mode window|tray] [--no-gui] + 打包后: plugin_manager.exe [--endpoint tcp://127.0.0.1:5555] [--mode window|tray] [--no-gui] + +启动模式: + window - 启动后显示主窗口 (默认) + tray - 启动后在系统托盘运行,不弹出主窗口 + --no-gui - 完全无界面(后台模式) +""" + +import argparse +import sys + + +def main() -> int: + parser = argparse.ArgumentParser(description="Solvable-Minesweeper 插件管理器") + parser.add_argument( + "--endpoint", + default="tcp://127.0.0.1:5555", + help="ZMQ Server 地址 (默认: tcp://127.0.0.1:5555)", + ) + parser.add_argument( + "--mode", + choices=["window", "tray"], + default="window", + help="GUI 模式: window=显示主窗口, tray=系统托盘 (默认: window)", + ) + parser.add_argument( + "--no-gui", + action="store_true", + default=False, + help="不显示界面(后台模式)", + ) + + args = parser.parse_args() + + # 初始化 loguru 日志系统(主日志 + 控制台) + from .app_paths import get_log_dir + from .logging_setup import init_logging + + init_logging(get_log_dir(), console=True, level="DEBUG") + + from . import run_plugin_manager_process + + show_main_window = not args.no_gui and args.mode == "window" + + return run_plugin_manager_process( + endpoint=args.endpoint, + with_gui=not args.no_gui, + show_main_window=show_main_window, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/plugin_manager/_run.py b/src/plugin_manager/_run.py new file mode 100644 index 0000000..1ee171d --- /dev/null +++ b/src/plugin_manager/_run.py @@ -0,0 +1,77 @@ +""" +插件管理器独立入口(供 PyInstaller 打包使用) + +替代 __main__.py,避免相对导入问题。 +python -m plugin_manager 仍走 __main__.py(开发模式) +打包后使用此脚本作为入口。 +""" + +import argparse +import sys + + +def main() -> int: + parser = argparse.ArgumentParser(description="Solvable-Minesweeper 插件管理器") + parser.add_argument( + "--endpoint", + default="tcp://127.0.0.1:5555", + help="ZMQ Server 地址 (默认: tcp://127.0.0.1:5555)", + ) + parser.add_argument( + "--mode", + choices=["window", "tray"], + default="window", + help="GUI 模式: window=显示主窗口, tray=系统托盘 (默认: window)", + ) + parser.add_argument( + "--no-gui", + action="store_true", + default=False, + help="不显示界面(后台模式)", + ) + parser.add_argument( + "--debug", + action="store_true", + default=False, + help="启用 debugpy 远程调试,等待 VS Code 附加 (端口 5678)", + ) + parser.add_argument( + "--debug-port", + type=int, + default=5678, + help="debugpy 监听端口 (默认: 5678)", + ) + + args = parser.parse_args() + + # 可选:启动 debugpy 等待远程调试附加 + if args.debug: + try: + import debugpy + # in_process_debug_adapter=True: 不启动子进程,直接在当前进程中运行 adapter + # 解决 PyInstaller 打包后子进程找不到 Python/debugpy 的问题 + debugpy.listen(("0.0.0.0", args.debug_port), in_process_debug_adapter=True) + print(f"[debug] Waiting for debugger attach on port {args.debug_port}...") + debugpy.wait_for_client() + print("[debug] Debugger attached, continuing...") + except ImportError as e: + print(f"[WARN] --debug set but debugpy import failed: {e}") + + from plugin_manager.app_paths import get_log_dir + from plugin_manager.logging_setup import init_logging + + init_logging(get_log_dir(), console=True, level="DEBUG") + + from plugin_manager import run_plugin_manager_process + + show_main_window = not args.no_gui and args.mode == "window" + + return run_plugin_manager_process( + endpoint=args.endpoint, + with_gui=not args.no_gui, + show_main_window=show_main_window, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/plugin_manager/app_paths.py b/src/plugin_manager/app_paths.py new file mode 100644 index 0000000..409d1b6 --- /dev/null +++ b/src/plugin_manager/app_paths.py @@ -0,0 +1,170 @@ +""" +应用路径工具(PyInstaller 兼容) + +解决 PyInstaller 打包后的路径问题: +- sys.frozen=True 时运行在 _MEIPASS 临时目录中 +- 需要区分:只读资源路径 vs 可写数据路径 vs 插件发现路径 +""" +from __future__ import annotations + +import sys +import os +from pathlib import Path + +import loguru +logger = loguru.logger.bind(name="AppPaths") + + +# ── 运行环境判断 ────────────────────────────────────── + +def is_frozen() -> bool: + """是否运行在 PyInstaller 打包环境中""" + return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS") + + +def get_bundle_dir() -> Path: + """ + 获取应用根目录(只读) + + - 开发模式: src/ 目录 + - 打包模式: _MEIPASS 临时解压目录 + """ + if is_frozen(): + return Path(sys._MEIPASS) + # 开发模式:返回 src/ 所在目录(即项目根目录的子目录) + return Path(__file__).resolve().parent.parent + + +def get_executable_dir() -> Path: + """ + 获取可执行文件所在目录(可写) + + - 开发模式: 项目根目录 + - 打包模式: exe 文件所在目录(用户可在此放插件) + """ + if is_frozen(): + return Path(sys.executable).resolve().parent + # 开发模式:src/ 的上级即项目根目录 + return get_bundle_dir().parent + + +def get_data_dir() -> Path: + """ + 获取可写的数据目录(用于存放状态文件、日志等持久化数据) + + - 开发模式: /src/data/ + - 打包模式: /data/ + """ + base = get_executable_dir() + data_dir = base / "data" + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir + + +def get_log_dir() -> Path: + """ + 获取日志目录(用于 loguru 输出) + + - 开发模式: /src/data/logs/ + - 打包模式: /data/logs/ + """ + log_dir = get_data_dir() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + return log_dir + + +# ── 插件路径 ────────────────────────────────────────── + +def get_builtin_plugin_dirs() -> list[Path]: + """ + 获取内置插件搜索目录列表 + + 内置插件随应用一起分发: + - 开发模式: /src/plugins/ + - 打包模式: <_MEIPASS>/src/plugins/ (PyInstaller 打包时需用 collect_subdirs 或 Tree 收集) + """ + bundle = get_bundle_dir() + plugin_dir = bundle / "plugins" + if plugin_dir.is_dir(): + return [plugin_dir] + logger.warning("内置插件目录不存在: %s", plugin_dir) + return [] + + +def get_user_plugin_dirs() -> list[Path]: + """ + 获取用户自定义插件目录(外部,用户自行添加的插件) + + - 开发模式: <项目根目录>/src/plugins/, <项目根目录>/src/user_plugins/ + - 打包模式: /plugins/, /user_plugins/ + """ + base = get_executable_dir() + result: list[Path] = [] + for name in ("plugins", "user_plugins"): + d = base / name + if d.is_dir(): + result.append(d) + return result + + +def get_all_plugin_dirs() -> list[Path]: + """获取所有插件搜索目录:内置 + 用户自定义""" + return get_builtin_plugin_dirs() + get_user_plugin_dirs() + + +# ── 环境变量补丁(给子进程使用) ─────────────────────── + +def patch_sys_path_for_frozen() -> None: + """ + 将 bundle 目录加入 sys.path,确保动态导入能找到模块 + + 在打包模式下,PyInstaller 只把显式收集的模块放入 _MEIPASS。 + 动态导入的插件如果依赖其他内部模块,需要确保这些模块也在 bundle 中, + 且 sys.path 能找到它们。 + """ + if not is_frozen(): + return + bundle = str(get_bundle_dir()) + if bundle not in sys.path: + sys.path.insert(0, bundle) + logger.debug("已将 bundle 目录加入 sys.path: %s", bundle) + + +def get_env_for_subprocess(env: dict | None = None) -> dict: + """ + 为启动插件管理器子进程构建环境变量 + + 确保 PYTHONPATH 包含正确的路径,使子进程中的动态导入正常工作。 + """ + if env is None: + env = dict(os.environ) + + bundle = str(get_bundle_dir()) + exec_dir = str(get_executable_dir()) + + # PYTHONPATH: bundle 目录优先(包含所有打包的代码) + existing = env.get("PYTHONPATH", "") + paths = [bundle] + if existing: + paths.append(existing) + env["PYTHONPATH"] = os.pathsep.join(paths) + + logger.debug("子进程环境: PYTHONPATH=%s", env.get("PYTHONPATH")) + return env + + +# ── 调试辅助 ────────────────────────────────────────── + +def debug_dump_paths() -> dict[str, str]: + """输出当前所有路径信息(调试用)""" + return { + "frozen": str(is_frozen()), + "bundle_dir": str(get_bundle_dir()), + "executable_dir": str(get_executable_dir()), + "data_dir": str(get_data_dir()), + "builtin_plugins": str(get_builtin_plugin_dirs()), + "user_plugins": str(get_user_plugin_dirs()), + "sys_executable": sys.executable, + "_MEIPASS": getattr(sys, "_MEIPASS", "N/A"), + "__file__": str(Path(__file__).resolve()), + } diff --git a/src/plugin_manager/event_dispatcher.py b/src/plugin_manager/event_dispatcher.py new file mode 100644 index 0000000..3120c6b --- /dev/null +++ b/src/plugin_manager/event_dispatcher.py @@ -0,0 +1,166 @@ +""" +事件分发器 + +负责将事件分发给订阅了该事件的插件 +支持优先级排序和异常隔离 +""" +from __future__ import annotations + +import threading +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable + +import loguru + +if TYPE_CHECKING: + from .plugin_base import BasePlugin + +logger = loguru.logger.bind(name="EventDispatcher") + + +@dataclass +class HandlerEntry: + """事件处理函数条目""" + handler: Callable[[Any], None] + priority: int + plugin: BasePlugin | None = None + + +class EventDispatcher: + """ + 事件分发器 + + 功能: + - 管理事件订阅 + - 按优先级分发事件 + - 异常隔离(一个处理函数出错不影响其他) + - 线程安全 + """ + + def __init__(self): + self._handlers: dict[str, list[HandlerEntry]] = defaultdict(list) + self._lock = threading.RLock() + + def subscribe( + self, + event_type: str, + handler: Callable[[Any], None], + priority: int = 100, + plugin: BasePlugin | None = None, + ) -> None: + """ + 订阅事件 + + Args: + event_type: 事件类型名称 + handler: 事件处理函数 + priority: 优先级(数值越小越先执行) + plugin: 所属插件(用于取消订阅) + """ + with self._lock: + entry = HandlerEntry( + handler=handler, + priority=priority, + plugin=plugin, + ) + self._handlers[event_type].append(entry) + # 按优先级排序 + self._handlers[event_type].sort(key=lambda e: e.priority) + logger.debug( + f"Subscribed to '{event_type}' with priority {priority}" + ) + + def unsubscribe(self, event_type: str, plugin: BasePlugin) -> None: + """ + 取消插件对某事件的所有订阅 + + Args: + event_type: 事件类型名称 + plugin: 要取消订阅的插件 + """ + with self._lock: + handlers = self._handlers.get(event_type) + if handlers: + self._handlers[event_type] = [ + entry for entry in handlers + if entry.plugin != plugin + ] + logger.debug(f"Unsubscribed plugin '{plugin.name}' from '{event_type}'") + + def unsubscribe_all(self, plugin: BasePlugin) -> None: + """ + 取消插件的所有事件订阅 + + Args: + plugin: 要取消订阅的插件 + """ + with self._lock: + for event_type in list(self._handlers.keys()): + self._handlers[event_type] = [ + entry for entry in self._handlers[event_type] + if entry.plugin != plugin + ] + + def dispatch(self, event_type: str, event: Any) -> None: + """ + 分发事件给所有订阅者 + + Args: + event_type: 事件类型名称 + event: 事件数据 + """ + with self._lock: + handlers = list(self._handlers.get(event_type, [])) + + if not handlers: + logger.debug(f"No handlers for event '{event_type}'") + return + + logger.debug( + f"Dispatching '{event_type}' to {len(handlers)} handler(s)" + ) + + for entry in handlers: + # 检查插件是否启用 + if entry.plugin and not entry.plugin.is_enabled: + continue + + try: + entry.handler(event) + except Exception as e: + plugin_name = entry.plugin.name if entry.plugin else "unknown" + logger.error( + f"Handler error in plugin '{plugin_name}' " + f"for event '{event_type}': {e}", + exc_info=True, + ) + + def dispatch_async(self, event_type: str, event: Any) -> None: + """ + 异步分发事件(在新线程中执行) + + Args: + event_type: 事件类型名称 + event: 事件数据 + """ + thread = threading.Thread( + target=self.dispatch, + args=(event_type, event), + daemon=True, + ) + thread.start() + + def get_handlers(self, event_type: str) -> list[HandlerEntry]: + """获取某事件的所有处理函数""" + with self._lock: + return list(self._handlers.get(event_type, [])) + + def clear(self) -> None: + """清除所有订阅""" + with self._lock: + self._handlers.clear() + + def __repr__(self) -> str: + total = sum(len(handlers) for handlers in self._handlers.values()) + return f"" diff --git a/src/plugin_manager/logging_setup.py b/src/plugin_manager/logging_setup.py new file mode 100644 index 0000000..10f2b04 --- /dev/null +++ b/src/plugin_manager/logging_setup.py @@ -0,0 +1,155 @@ +""" +Loguru 日志初始化模块 + +配置规则: +|- 插件管理器主日志 → /main.log(所有非插件日志) +|- 每个插件独立日志 → /plugins/.log +|- 控制台输出带颜色 +|- 日志轮转:按大小自动清理(插件可在 PluginInfo 中自定义) + +使用方式: + from plugin_manager.logging_setup import init_logging, get_plugin_logger, LogConfig + + init_logging(log_dir) # 启动时调用一次 + logger = get_plugin_logger("game_monitor") # 插件获取自己的 logger +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import loguru + + +@dataclass(frozen=True) +class LogConfig: + """日志轮转配置,插件可通过 PluginInfo.log_config 自定义""" + rotation: str = "10 MB" # 单文件最大大小,超出后自动轮转 + retention: int = 5 # 保留的备份文件数(0 = 不删除旧文件) + + +#: 全局默认配置,插件不声明时使用此值 +DEFAULT_LOG_CONFIG = LogConfig() + +# 保存控制台 sink ID,用于清理 +_console_sink_id: Optional[int] = None + + +def init_logging( + log_dir: Path | str, + *, + console: bool = True, + level: str = "DEBUG", + log_config: LogConfig | None = None, +) -> None: + """ + 初始化日志系统(仅调用一次) + + Args: + log_dir: 日志根目录 + console: 是否输出到控制台 + level: 控制台最低级别 + log_config: 日志轮转配置(默认 10MB / 5个备份) + """ + global _console_sink_id + _logger = loguru.logger + cfg = log_config or DEFAULT_LOG_CONFIG + + log_dir = Path(log_dir) + log_dir.mkdir(parents=True, exist_ok=True) + + # 移除 loguru 默认的 stderr sink,避免重复输出 + _logger.remove() + + # ── 主日志文件 (main.log) ── + main_log = log_dir / "main.log" + _logger.add( + str(main_log), + level="DEBUG", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<7} | {name}:{function}:{line} | {message}", + rotation=cfg.rotation, + retention=cfg.retention, + encoding="utf-8", + ) + + # ── 控制台输出(可选)── + if console: + _console_sink_id = _logger.add( + lambda msg: print(msg, end="", flush=True), + level=level, + format=( + "{time:HH:mm:ss} | " + "{level:<7} | " + "{name}:{function} " + "| {message}" + ), + colorize=True, + ) + + # ── 插件日志目录预创建 ── + (log_dir / "plugins").mkdir(parents=True, exist_ok=True) + + +def get_plugin_logger( + plugin_name: str, + *, + log_dir: Path | str | None = None, + log_config: LogConfig | None = None, +) -> tuple["loguru.Logger", int]: + """ + 获取插件的专用 logger(每个插件一个独立日志文件) + + Args: + plugin_name: 插件名称(如 "game_monitor") + log_dir: 可选,覆盖默认的插件日志目录 + log_config: 日志轮转配置,None 使用全局默认值 + + Returns: + (logger, sink_id) 元组,sink_id 用于动态修改日志级别 + + Usage:: + + self.logger, self._log_sink_id = get_plugin_logger(self.name) + self.logger.info("插件启动了") + """ + if log_dir is None: + from .app_paths import get_data_dir + log_dir = get_data_dir() / "logs" / "plugins" + else: + log_dir = Path(log_dir) / "plugins" + + cfg = log_config or DEFAULT_LOG_CONFIG + + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / f"{plugin_name}.log" + + lg = loguru.logger.bind(plugin=plugin_name) + + # 绑定专属文件 sink:只接收该插件的日志 + sink_id = lg.add( + str(log_file), + level="DEBUG", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<7} | {message}", + rotation=cfg.rotation, + retention=cfg.retention, + encoding="utf-8", + filter=lambda rec, pn=plugin_name: rec["extra"].get("plugin") == pn, + ) + return lg, sink_id + + +def set_plugin_log_level(sink_id: int, level: str = "DEBUG") -> None: + """ + 动态修改某个插件日志 sink 的级别 + + Args: + sink_id: get_plugin_logger 返回的 sink_id + level: 新的日志级别 ("TRACE", "DEBUG", "INFO", "WARNING", "ERROR") + """ + config = loguru.logger._core + handler = config.handlers.get(sink_id) + if handler is not None: + from loguru._logger import Level + handler._levelno = Level(level).no # type: ignore[union-attr] + handler._levelname = level # type: ignore[union-attr] diff --git a/src/plugin_manager/main_window.py b/src/plugin_manager/main_window.py new file mode 100644 index 0000000..e205f0b --- /dev/null +++ b/src/plugin_manager/main_window.py @@ -0,0 +1,1020 @@ +""" +插件管理器主窗口 + +独立进程的主界面,用于管理和展示插件 +- 支持标签页双击/拖拽弹出为独立窗口 +- 支持关闭独立窗口自动嵌回标签页 +- 增强型连接状态显示(含 endpoint、重连次数、实时心跳检测) +""" +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt, pyqtSignal, QPoint, QTimer +from PyQt5.QtGui import QMouseEvent, QIcon, QPixmap +from PyQt5.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDialogButtonBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QListView, + QMainWindow, + QMessageBox, + QMenu, + QPushButton, + QStatusBar, + QSystemTrayIcon, + QTabBar, + QTabWidget, + QVBoxLayout, + QWidget, + QDialog, +) + +from .plugin_state import PluginStateManager, PluginState +from .plugin_base import WindowMode, LogLevel +from .app_paths import get_data_dir + +if TYPE_CHECKING: + from .plugin_manager import PluginManager + +import loguru +logger = loguru.logger.bind(name="MainWindow") + + +# ═══════════════════════════════════════════════════════════════════ +# 可分离标签页组件 +# ═══════════════════════════════════════════════════════════════════ + +class DetachedPluginWindow(QDialog): + """ + 弹出的独立插件窗口 + + 特性: + - 关闭时自动将 widget 嵌回主窗口标签页 + - 标题栏显示"📎 嵌回"提示 + """ + + # 信号:窗口被用户关闭,请求将 widget 嵌回标签页 + embed_requested = pyqtSignal(str) + + def __init__(self, plugin_name: str, widget: QWidget, parent=None): + super().__init__(parent) + self._plugin_name = plugin_name + self._widget = widget + self._icon: QIcon | None = None + + self.setWindowTitle(f"{plugin_name} - {self.tr('插件')}") + self.setMinimumSize(400, 300) + self.resize(600, 450) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # 顶部提示栏 + hint_bar = QLabel(self.tr("📎 关闭此窗口可自动嵌回到标签页")) + hint_bar.setAlignment(Qt.AlignCenter) + hint_bar.setStyleSheet(""" + QLabel { + background: #e8f4fd; + color: #1976d2; + padding: 4px; + font-size: 12px; + border-bottom: 1px solid #b3d9ff; + } + """) + layout.addWidget(hint_bar) + + # 将 widget 从旧父窗口转移到新窗口,并确保可见 + widget.setParent(self) + layout.addWidget(widget) + widget.setVisible(True) + widget.show() + + def closeEvent(self, event) -> None: + """关闭时发出嵌入请求信号,不销毁 widget""" + self.embed_requested.emit(self._plugin_name) + self.hide() + event.ignore() + + +class _DetachableTabBar(QTabBar): + """支持拖拽弹出的标签栏""" + + drag_initiated = pyqtSignal(int, str, QPoint) # index, name, global_pos + + def __init__(self, parent=None): + super().__init__(parent) + self._drag_start_pos: QPoint | None = None + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.LeftButton: + self._drag_start_pos = event.globalPos() + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + if ( + self._drag_start_pos is not None + and (event.buttons() & Qt.LeftButton) + ): + distance = (event.globalPos() - self._drag_start_pos).manhattanLength() + if distance > QApplication.startDragDistance(): + idx = self.tabAt(self.mapFromGlobal(self._drag_start_pos)) + if idx >= 0: + # 判断是否向下拖出了标签栏区域(垂直偏移大) + delta_y = event.globalPos().y() - self._drag_start_pos.y() + if abs(delta_y) > QApplication.startDragDistance(): + name = self.tabText(idx) + self.drag_initiated.emit(idx, name, event.globalPos()) + self._drag_start_pos = None + return + super().mouseMoveEvent(event) + + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.LeftButton: + idx = self.tabAt(event.pos()) + if idx >= 0: + name = self.tabText(idx) + self.drag_initiated.emit(idx, name, event.globalPos()) + return + super().mouseDoubleClickEvent(event) + + +class DetachableTabWidget(QTabWidget): + """ + 可分离的 TabWidget + + 操作方式: + - 双击标签页 → 弹出为独立窗口 + - 向下拖拽标签页 → 弹出为独立窗口 + - 关闭独立窗口 → 自动嵌回 + """ + + tab_detached = pyqtSignal(str) # 标签页被弹出 (plugin_name) + tab_attach_requested = pyqtSignal(str) # 请求嵌回 (plugin_name) + tab_close_requested = pyqtSignal(str) # 请求关闭标签页 (plugin_name) + + def __init__(self, parent=None): + super().__init__(parent) + + # 用自定义的标签栏替换默认的 + self._tab_bar = _DetachableTabBar(self) + self.setTabBar(self._tab_bar) + + self.setDocumentMode(True) + self.setTabsClosable(True) # 每个标签页显示关闭按钮 + self.setMovable(True) # 允许内部重排序 + + self._detached_windows: dict[str, DetachedPluginWindow] = {} + + # 连接自定义标签栏的拖拽信号 + self._tab_bar.drag_initiated.connect(self._on_drag_from_bar) + + # 连接关闭按钮信号 + self.tabCloseRequested.connect(self._on_tab_close_requested) + + def add_detachable_tab(self, widget: QWidget, name: str, icon=None) -> int: + """添加一个可分离的标签页,返回 index""" + idx = self.addTab(widget, name) + if icon is not None: + self.setTabIcon(idx, icon) + return idx + + def _on_drag_from_bar(self, index: int, name: str, pos: QPoint) -> None: + """响应标签栏发起的拖拽/双击弹出""" + self._detach_tab(index, name, pos=pos) + + def _detach_tab(self, index: int, name: str, pos: QPoint | None = None) -> None: + """将指定标签页弹出为独立窗口""" + widget = self.widget(index) + if widget is None: + return + + icon = self.tabIcon(index) + self.removeTab(index) + + window = DetachedPluginWindow(name, widget, parent=self.window()) + window._icon = icon + if icon and not icon.isNull(): + window.setWindowIcon(icon) + window.embed_requested.connect(self._attach_tab) + self._detached_windows[name] = window + + if pos is not None: + window.move(pos) + window.show() + window.activateWindow() + + self.tab_detached.emit(name) + + def _attach_tab(self, name: str) -> None: + """将弹出的窗口嵌回标签页""" + if name not in self._detached_windows: + return + + window = self._detached_windows[name] + saved_icon = window._icon # type: ignore[attr-defined] + # 防止重复调用:手动关闭时已清理过的情况 + lay = window.layout() + if lay is None or lay.count() < 2: + self._cleanup_detached(name) + return + + item = lay.itemAt(1) + if item is None: + self._cleanup_detached(name) + return + widget = item.widget() + if widget is None: + self._cleanup_detached(name) + return + + # 从窗口取出 widget + window.layout().removeWidget(widget) + widget.setParent(None) + + window.deleteLater() + del self._detached_windows[name] + + # 检查是否已存在同名标签页 + for i in range(self.count()): + if self.tabText(i) == name: + self.insertTab(i, widget, name) + if saved_icon and not saved_icon.isNull(): + self.setTabIcon(i, saved_icon) + widget.setVisible(True) + return + + idx = self.addTab(widget, name) + if saved_icon and not saved_icon.isNull(): + self.setTabIcon(idx, saved_icon) + widget.setVisible(True) + self.tab_attach_requested.emit(name) + + def _on_tab_close_requested(self, index: int) -> None: + """处理标签页关闭按钮点击""" + name = self.tabText(index) + widget = self.widget(index) + if widget is None: + return + # 隐藏并移除标签页,不销毁 widget(保留插件数据) + self.removeTab(index) + widget.hide() + widget.setParent(None) + self.tab_close_requested.emit(name) + + +# ═══════════════════════════════════════════════════════════════════ +# 连接状态组件 +# ═══════════════════════════════════════════════════════════════════ + +class ConnectionStatusWidget(QWidget): + """增强型连接状态显示组件""" + + def __init__(self, endpoint: str, parent=None): + super().__init__(parent) + self._endpoint = endpoint + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # 状态指示灯 + self._status_label = QLabel(self.tr("● 未连接")) + self._status_label.setStyleSheet("color: red; font-weight: bold;") + layout.addWidget(self._status_label) + + # 重连次数 + self._reconnect_label = QLabel("") + self._reconnect_label.setStyleSheet( + "color: orange; font-size: 11px; margin-left: 6px;" + ) + layout.addWidget(self._reconnect_label) + + layout.addStretch() + + # 端点地址 + ep_label = QLabel(endpoint) + ep_label.setStyleSheet("color: gray; font-size: 11px;") + layout.addWidget(ep_label) + + def set_status( + self, + connected: bool, + reconnect_count: int = 0, + ) -> None: + """更新连接状态显示""" + if connected: + self._status_label.setText(self.tr("● 已连接")) + self._status_label.setStyleSheet("color: green; font-weight: bold;") + if reconnect_count > 0: + self._reconnect_label.setText( + self.tr("(重连 {n} 次").format(n=reconnect_count) + ) + self._reconnect_label.show() + else: + self._reconnect_label.hide() + else: + self._status_label.setText(self.tr("● 未连接")) + self._status_label.setStyleSheet("color: red; font-weight: bold;") + if reconnect_count > 0: + self._reconnect_label.setText( + self.tr("(断开, 重连 {n} 次)").format(n=reconnect_count) + ) + self._reconnect_label.show() + else: + self._reconnect_label.hide() + + +# ═══════════════════════════════════════════════════════════════════ +# 插件设置编辑对话框 +# ═══════════════════════════════════════════════════════════════════ + +class PluginSettingsDialog(QDialog): + """编辑单个插件的持久化状态""" + + def __init__(self, plugin_name: str, state: PluginState, parent=None): + super().__init__(parent) + self._name = plugin_name + + self.setWindowTitle(self.tr("插件设置 - {n}").format(n=plugin_name)) + self.setMinimumWidth(380) + self.setModal(True) + + layout = QVBoxLayout(self) + + # ── 启用 / 显示窗口 ── + grp = QGroupBox(self.tr("基本设置")) + form = QFormLayout(grp) + + self._chk_enabled = QCheckBox() + self._chk_enabled.setChecked(state.enabled) + form.addRow(self.tr("启用插件:"), self._chk_enabled) + + self._chk_show = QCheckBox() + self._chk_show.setChecked(state.show_window) + form.addRow(self.tr("启动时显示窗口:"), self._chk_show) + layout.addWidget(grp) + + # ── 窗口模式 ── + grp2 = QGroupBox(self.tr("窗口加载方式")) + form2 = QFormLayout(grp2) + + self._combo_mode = QComboBox() + for mode in WindowMode._values(): + label = WindowMode.LABELS.get(mode, mode) + self._combo_mode.addItem(label, mode) + idx = self._combo_mode.findData(state.window_mode.value if isinstance(state.window_mode, WindowMode) else str(state.window_mode)) + if idx >= 0: + self._combo_mode.setCurrentIndex(idx) + else: + self._combo_mode.setCurrentIndex(0) # default to tab + form2.addRow(self.tr("窗口位置:"), self._combo_mode) + layout.addWidget(grp2) + + # ── 日志级别 ── + grp3 = QGroupBox(self.tr("日志设置")) + form3 = QFormLayout(grp3) + + self._combo_loglevel = QComboBox() + for level in LogLevel._values(): + label = LogLevel.LABELS.get(level, level) + self._combo_loglevel.addItem(label, level) + _lvl_idx = self._combo_loglevel.findData( + state.log_level.value if isinstance(state.log_level, LogLevel) else str(state.log_level).upper() + ) + if _lvl_idx >= 0: + self._combo_loglevel.setCurrentIndex(_lvl_idx) + else: + self._combo_loglevel.setCurrentIndex(1) # default DEBUG + form3.addRow(self.tr("日志级别:"), self._combo_loglevel) + layout.addWidget(grp3) + + layout.addStretch() + + # 按钮 + btns = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + btns.accepted.connect(self.accept) + btns.rejected.connect(self.reject) + layout.addWidget(btns) + + @property + def result_state(self) -> PluginState: + return PluginState( + enabled=self._chk_enabled.isChecked(), + show_window=self._chk_show.isChecked(), + window_mode=WindowMode(str(self._combo_mode.currentData())), + log_level=LogLevel(str(self._combo_loglevel.currentData())), + ) + + +# ═══════════════════════════════════════════════════════════════════ +# 主窗口 +# ═══════════════════════════════════════════════════════════════════ + +class PluginManagerWindow(QMainWindow): + """插件管理器主窗口""" + + connection_changed = pyqtSignal(bool) + + def __init__(self, plugin_manager: PluginManager, parent=None): + super().__init__(parent) + + self._manager = plugin_manager + + # 状态持久化 + self._state_mgr = PluginStateManager(get_data_dir() / "plugin_states.json") + self._state_mgr.load() + + self.setWindowTitle(self.tr("插件管理器")) + self.setMinimumSize(800, 600) + + self._setup_ui() + self._connect_signals() + self._setup_tray_icon() + + # 应用已保存的状态到插件 + self._apply_saved_states() + + self._refresh_plugin_list() + + # 定时刷新连接状态 + self._timer = QTimer(self) + self._timer.timeout.connect(self._poll_connection_status) + self._timer.start(1000) + + # ── UI ─────────────────────────────────────────────── + + def _setup_ui(self) -> None: + """构建界面""" + # 主布局:左侧插件列表 + 右侧标签页 + main_splitter = QWidget() + main_layout = QHBoxLayout(main_splitter) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(2) + + # ── 左侧:插件列表面板 ── + left_panel = QWidget() + 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) + + # 插件列表 + lst = QListWidget() + lst.setViewMode(QListView.ListMode) + lst.setSelectionMode(QListView.SingleSelection) + lst.setEditTriggers(QListView.NoEditTriggers) + lst.setContextMenuPolicy(Qt.CustomContextMenu) + 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) + + # 记录被关闭(但未销毁)的插件名称 + self._closed_plugins: set[str] = set() + + # ── 右侧:可分离标签页 ── + self._tab_widget = DetachableTabWidget() + self._tab_widget.tab_detached.connect(self._on_tab_detached) + self._tab_widget.tab_attach_requested.connect(self._on_tab_attached) + self._tab_widget.tab_close_requested.connect(self._on_tab_closed) + main_layout.addWidget(self._tab_widget, stretch=1) + + self.setCentralWidget(main_splitter) + + # 状态栏 + bar = QStatusBar() + self.setStatusBar(bar) + + conn_w = ConnectionStatusWidget(self._manager.connection_endpoint) + bar.addPermanentWidget(conn_w) + self._conn_status = conn_w + bar.showMessage(self.tr("正在连接...")) + + def _setup_tray_icon(self) -> None: + """创建系统托盘图标,关闭主窗口时最小化到托盘""" + if not QSystemTrayIcon.isSystemTrayAvailable(): + logger.warning("系统不支持托盘图标") + return + + icon = self._create_tray_icon() + tray = QSystemTrayIcon(icon, self) + tray.setToolTip(self.tr("插件管理器 - 右键打开菜单")) + + menu = QMenu(self) + act_show = menu.addAction(self.tr("显示主窗口")) + act_show.triggered.connect(self.show_and_raise) + menu.addSeparator() + act_quit = menu.addAction(self.tr("退出")) + act_quit.triggered.connect(self._really_quit) + tray.setContextMenu(menu) + + tray.activated.connect(self._on_tray_activated) + tray.show() + self._tray_icon = tray + + @staticmethod + def _create_tray_icon() -> QIcon: + """生成一个简单的托盘图标(蓝色圆形 + 插件符号)""" + pix = QPixmap(64, 64) + pix.fill(Qt.transparent) + from PyQt5.QtGui import QPainter, QPen, QColor, QBrush, QFont + p = QPainter(pix) + p.setRenderHint(QPainter.Antialiasing) + # 蓝色圆形背景 + p.setPen(Qt.NoPen) + p.setBrush(QBrush(QColor("#1976d2"))) + p.drawEllipse(pix.rect().adjusted(3, 3, -3, -3)) + # 白色 "P" 字母 + pen = QPen(QColor("white"), 2) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + font = QFont("Arial", 32, QFont.Bold) + p.setFont(font) + p.drawText(pix.rect(), Qt.AlignCenter, "P") + p.end() + return QIcon(pix) + + def show_and_raise(self) -> None: + """显示主窗口并置顶""" + if not self.isVisible(): + self.show() + self.activateWindow() + self.raise_() + + def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: + """托盘图标被双击时恢复窗口""" + if reason == QSystemTrayIcon.DoubleClick: + self.show_and_raise() + + def _really_quit(self) -> None: + """真正退出程序(从托盘菜单触发)""" + self._tray_icon.hide() + self._state_mgr.save() + self._manager.stop() + QApplication.instance().quit() + + def _connect_signals(self) -> None: + self._refresh_btn.clicked.connect(self._refresh_plugin_list) + self._list.itemDoubleClicked.connect(self._on_list_double_clicked) + self._list.customContextMenuRequested.connect(self._on_list_context_menu) + self.connection_changed.connect(self._on_conn_changed) + + # 调试开关 + self._debug_btn.toggled.connect(self._toggle_debug) + + # ── 连接状态 ──────────────────────────────────────── + + def set_connected(self, ok: bool) -> None: + self.connection_changed.emit(ok) + + def _on_conn_changed(self, ok: bool) -> None: + rc = self._manager.reconnect_count + self._conn_status.set_status(ok, rc) + self._conn_btn.setChecked(ok) + self._conn_btn.setText( + self.tr("已连接") if ok else self.tr("连接") + ) + msg = ( + self.tr("已连接到主进程") + if ok + else self.tr("未连接 (重连 {n} 次)").format(n=rc) if rc + else self.tr("未连接到主进程") + ) + self.statusBar().showMessage(msg) + + def _poll_connection_status(self) -> None: + try: + ok = self._manager.is_connected + rc = self._manager.reconnect_count + self._conn_status.set_status(ok, rc) + except Exception: + pass + + # ── 远程调试 ──────────────────────────────────────── + + _debug_active: bool = False + + def _toggle_debug(self, enabled: bool) -> None: + """开启/关闭 debugpy 远程调试""" + if enabled: + self._start_debug() + else: + self._stop_debug() + + def _start_debug(self) -> None: + """启动 debugpy 监听""" + try: + import debugpy + # in_process_debug_adapter=True: 不启动子进程,直接在当前进程中运行 adapter + # 解决 PyInstaller 打包后子进程找不到 Python/debugpy 的问题 + debugpy.listen(("0.0.0.0", 5678), in_process_debug_adapter=True) + PluginManagerWindow._debug_active = True + self._debug_btn.setText("🐛 Listening...") + self._debug_btn.setStyleSheet("background: #4caf50; color: white; font-weight: bold;") + self.statusBar().showMessage(self.tr("Debug server listening on port 5678, waiting for VS Code attach...")) + logger.info("Debug server started on port 5678") + except ImportError as e: + self._debug_btn.setChecked(False) + QMessageBox.warning( + self, "Debug", + f"debugpy import failed:\n{e}", + ) + except Exception as e: + self._debug_btn.setChecked(False) + QMessageBox.warning(self, "Debug", f"Failed to start debugger:\n{e}") + + def _stop_debug(self) -> None: + """停止 debugpy""" + try: + import debugpy + debugpy.stop_listen() + except Exception: + pass + PluginManagerWindow._debug_active = False + self._debug_btn.setText("🐛 Debug") + self._debug_btn.setStyleSheet("") + self.statusBar().showMessage(self.tr("Debug stopped")) + logger.info("Debug server stopped") + + # ── 插件列表 ──────────────────────────────────────── + + # ── 插件列表 ──────────────────────────────────────── + + def _refresh_plugin_list(self) -> None: + """刷新插件列表和标签页""" + t = self._tab_widget + lst = self._list + t.setUpdatesEnabled(False) + lst.setUpdatesEnabled(False) + + # 保存当前选中项 + current_name = None + item = lst.currentItem() + if item: + current_name = item.data(Qt.UserRole) + + lst.clear() + while t.count() > 0: + t.removeTab(0) + + # 需要弹出的 detached 插件(延迟到 updatesEnabled 之后) + _pending_detach: list[str] = [] + + for name, p in self._manager.plugins.items(): + li = QListWidgetItem(p.name) + li.setData(Qt.UserRole, name) + li.setIcon(p.plugin_icon) + # 已禁用的用灰色 + if not p.is_enabled: + li.setForeground(Qt.gray) + lst.addItem(li) + + if p.widget and name not in t._detached_windows and name not in self._closed_plugins: + st = self._effective_state(name) + if st.window_mode == WindowMode.DETACHED: + t.add_detachable_tab(p.widget, name, icon=p.plugin_icon) + _pending_detach.append(name) + else: + t.add_detachable_tab(p.widget, name, icon=p.plugin_icon) + + # 恢复选中项 + if current_name: + for i in range(lst.count()): + if lst.item(i).data(Qt.UserRole) == current_name: + lst.setCurrentRow(i) + break + + t.setUpdatesEnabled(True) + lst.setUpdatesEnabled(True) + + # 延迟弹出到独立窗口 + for name in _pending_detach: + for i in range(t.count()): + if t.tabText(i) == name: + t._detach_tab(i, name) + break + + total = len(self._manager.plugins) + en = sum(1 for p in self._manager.plugins.values() if p.is_enabled) + self.statusBar().showMessage( + self.tr("已加载 {total} 个插件,{enabled} 个已启用").format( + total=total, enabled=en + ) + ) + + def _on_list_context_menu(self, pos) -> None: + """右键菜单""" + item = self._list.itemAt(pos) + if not item: + return + + name = item.data(Qt.UserRole) + plugin = self._manager.plugins.get(name) + if not plugin: + return + + menu = QMenu(self) + t = self._tab_widget + + # 启用/禁用 + act_enable = menu.addAction("✅ " + self.tr("启用")) + act_disable = menu.addAction("❌ " + self.tr("禁用")) + act_enable.setEnabled(not plugin.is_enabled) + act_disable.setEnabled(plugin.is_enabled) + act_enable.triggered.connect(lambda: self._toggle_plugin(name, True)) + act_disable.triggered.connect(lambda: self._toggle_plugin(name, False)) + + menu.addSeparator() + + # 插件详情(子菜单,只读) + detail_menu = QMenu("ℹ️ " + self.tr("插件详情"), self) + detail_menu.addAction(self.tr("名称: {name}").format(name=plugin.name)).setEnabled(False) + detail_menu.addAction(self.tr("版本: {v}").format(v=plugin.info.version)).setEnabled(False) + detail_menu.addAction(self.tr("作者: {a}").format(a=plugin.info.author or '-')).setEnabled(False) + desc = plugin.info.description or self.tr("暂无描述") + detail_menu.addAction(self.tr("描述: {d}").format(d=desc)).setEnabled(False) + menu.addMenu(detail_menu) + + menu.addSeparator() + + # 打开/关闭窗口 + has_tab = any(t.tabText(i) == name for i in range(t.count())) + has_detached = name in t._detached_windows + has_closed = name in self._closed_plugins + + act_open = menu.addAction("🖥 " + self.tr("打开窗口")) + act_close = menu.addAction("🚫 " + self.tr("关闭窗口")) + + can_open = (has_closed or (not has_tab and plugin.widget is not None)) + act_open.setEnabled(can_open) + act_close.setEnabled(has_tab or has_detached) + + # 打开日志文件 + act_log = menu.addAction("📋 " + self.tr("打开日志")) + + act_open.triggered.connect(lambda: self._open_plugin_window(name)) + act_close.triggered.connect(lambda: self._close_plugin_window(name)) + act_log.triggered.connect(lambda: self._open_plugin_log(name)) + + menu.addSeparator() + + # 设置 + act_settings = menu.addAction("⚙️ " + self.tr("设置...")) + act_settings.triggered.connect(lambda: self._open_plugin_settings(name)) + + menu.exec_(self._list.viewport().mapToGlobal(pos)) + + def _toggle_plugin(self, name: str, enable: bool) -> None: + """切换插件启用状态""" + if enable: + self._manager.enable_plugin(name) + else: + self._manager.disable_plugin(name) + self._sync_state(name, enabled=enable) + self._refresh_plugin_list() + + def _open_plugin_window(self, name: str) -> None: + """打开/恢复插件窗口""" + plugin = self._manager.plugins.get(name) + if not plugin or not plugin.widget: + return + + t = self._tab_widget + + # 如果已弹出为独立窗口,激活它 + if name in t._detached_windows: + w = t._detached_windows[name] + w.show() + w.activateWindow() + return + + # 如果已有标签页,切换过去 + for i in range(t.count()): + if t.tabText(i) == name: + t.setCurrentIndex(i) + return + + # 从关闭列表中移除并重新打开 + if name in self._closed_plugins: + self._closed_plugins.discard(name) + t.add_detachable_tab(plugin.widget, name, icon=plugin.plugin_icon) + + def _cleanup_detached(self, name: str) -> None: + """安全清理 detached 窗口引用""" + if name in self._detached_windows: + w = self._detached_windows[name] + w.blockSignals(True) # 阻止 closeEvent 再次触发 embed_requested + w.close() + w.deleteLater() + del self._detached_windows[name] + + def _close_plugin_window(self, name: str) -> None: + """关闭插件窗口(不销毁)""" + t = self._tab_widget + + # 关闭独立窗口 → 取出 widget 后关闭 + if name in t._detached_windows: + window = t._detached_windows[name] + lay = window.layout() + if lay is not None and lay.count() >= 2: + item = lay.itemAt(1) + if item is not None: + widget = item.widget() + if widget is not None: + lay.removeWidget(widget) + widget.setParent(None) + window.blockSignals(True) # 防止 closeEvent 二次触发 + window.close() + window.deleteLater() + del t._detached_windows[name] + + # 关闭标签页 + for i in range(t.count()): + if t.tabText(i) == name: + widget = t.widget(i) + t.removeTab(i) + widget.hide() + widget.setParent(None) + break + + self._closed_plugins.add(name) + # 同步状态:窗口模式 → closed + self._sync_state(name, window_mode=WindowMode.CLOSED) + + def _open_plugin_log(self, name: str) -> None: + """用系统默认程序打开插件日志文件""" + from .app_paths import get_log_dir + log_file = get_log_dir() / "plugins" / f"{name}.log" + if not log_file.exists(): + # 文件不存在时创建一个空文件,避免打开报错 + log_file.parent.mkdir(parents=True, exist_ok=True) + log_file.touch() + import subprocess + import os + try: + if os.name == "nt": + os.startfile(str(log_file)) # type: ignore[attr-defined] + else: + subprocess.Popen(["xdg-open", str(log_file)]) + except Exception as e: + logger.warning("Failed to open log file %s: %s", log_file, e) + + def _open_plugin_settings(self, name: str) -> None: + """打开插件设置对话框""" + current = self._effective_state(name) + dlg = PluginSettingsDialog(name, current, parent=self) + if dlg.exec_() == QDialog.Accepted: + new_state = dlg.result_state + self._state_mgr.set(name, new_state) + self._state_mgr.save() + # 立即应用启用/禁用 + if current.enabled != new_state.enabled: + if new_state.enabled: + self._manager.enable_plugin(name) + else: + self._manager.disable_plugin(name) + # 立即应用日志级别 + plugin = self._manager.plugins.get(name) + if plugin and current.log_level != new_state.log_level: + plugin.set_log_level(new_state.log_level) + self._refresh_plugin_list() + + # ── 状态持久化辅助 ──────────────────────────────── + + def _get_plugin_default_state(self, name: str) -> PluginState | None: + """从插件的 PluginInfo 读取声明的默认状态""" + p = self._manager.plugins.get(name) + if p: + return PluginState( + enabled=p.info.enabled, + show_window=p.info.show_window, + window_mode=p.info.window_mode, + log_level=p.info.log_level, + ) + return None + + def _effective_state(self, name: str) -> PluginState: + """获取插件的有效状态(JSON 覆盖 > 插件声明 > 系统默认)""" + return self._state_mgr.get_effective(name, self._get_plugin_default_state(name)) + + def _apply_saved_states(self) -> None: + """ + 启动时根据已保存的状态(或插件声明)设置: + - 启用/禁用 + - 窗口是否显示 + 加载方式 + - 日志级别 + """ + for name, p in self._manager.plugins.items(): + st = self._effective_state(name) + + # 启用/禁用 + if not st.enabled and p.is_enabled: + self._manager.disable_plugin(name) + + # 窗口加载方式:记录到 _closed_plugins 或稍后处理 detached + mode = st.window_mode + if not st.show_window or mode == WindowMode.CLOSED: + self._closed_plugins.add(name) + + # 日志级别 + try: + p.set_log_level(st.log_level) + except Exception: + pass + + def _sync_state(self, name: str, *, enabled: bool | None = None, + window_mode: WindowMode | None = None) -> None: + """将运行时变化同步到状态管理器(不立即写盘)""" + st = self._state_mgr.get(name) + changes = {} + if enabled is not None: + changes["enabled"] = enabled + if window_mode is not None: + changes["window_mode"] = window_mode + if changes: + ns = PluginState(**{**st.__dict__, **changes}) + self._state_mgr.set(name, ns) + + def _on_list_double_clicked(self, item) -> None: + """双击列表项:打开对应插件窗口""" + if not item: + return + name = item.data(Qt.UserRole) + self._open_plugin_window(name) + + # ── 标签页弹出/嵌回 ───────────────────────────────── + + def _on_tab_detached(self, name: str) -> None: + logger.debug("Tab detached: %s", name) + + def _on_tab_attached(self, name: str) -> None: + logger.debug("Tab attached back: %s", name) + + def _on_tab_closed(self, name: str) -> None: + logger.debug("Tab closed: %s", name) + self._closed_plugins.add(name) + + # ── 窗口事件 ──────────────────────────────────────── + + def closeEvent(self, event) -> None: + """关闭主窗口 → 最小化到系统托盘,不退出""" + if hasattr(self, "_tray_icon") and self._tray_icon.isVisible(): + event.ignore() + self.hide() + if self._tray_icon.supportsMessages(): + self._tray_icon.showMessage( + self.tr("插件管理器"), + self.tr("程序已在系统托盘中运行"), + QSystemTrayIcon.Information, + 2000, + ) + return + + # 无托盘支持时走原来的确认流程 + reply = QMessageBox.question( + self, + self.tr("确认关闭"), + self.tr("关闭窗口将停止插件管理器,确定吗?"), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self._state_mgr.save() + self._manager.stop() + event.accept() + else: + event.ignore() + + diff --git a/src/plugin_manager/plugin_base.py b/src/plugin_manager/plugin_base.py new file mode 100644 index 0000000..586e5f5 --- /dev/null +++ b/src/plugin_manager/plugin_base.py @@ -0,0 +1,348 @@ +""" +插件基类定义 + +每个插件同时具备: +- 后台数据处理能力(订阅事件、处理数据) +- 界面交互能力(可选的 PyQt 界面) + +注意:插件共享同一个 ZMQClient,事件通过 EventDispatcher 内部分发 +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast + +_E = TypeVar("_E", bound="BaseEvent") + +if TYPE_CHECKING: + from PyQt5.QtGui import QIcon + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPen, QColor, QBrush, QFont + +from lib_zmq_plugins.shared.base import BaseEvent, get_event_tag + +if TYPE_CHECKING: + from PyQt5.QtWidgets import QWidget + from lib_zmq_plugins.client.zmq_client import ZMQClient + from .event_dispatcher import EventDispatcher + + +def make_plugin_icon( + color: str = "#1976d2", + symbol: str = "?", + size: int = 64, +) -> QIcon: + """ + 生成插件默认图标的工厂函数 + + Args: + color: 圆形背景颜色(十六进制) + symbol: 圆心显示的文字/符号 + size: 图标像素尺寸 + + Returns: + 生成的 QIcon + + Usage:: + + PLUGIN_INFO = PluginInfo(..., icon=make_plugin_icon("#e65100", "📝")) + """ + pix = QPixmap(size, size) + pix.fill(Qt.transparent) # type: ignore[attr-defined] + + p = QPainter(pix) + p.setRenderHint(QPainter.Antialiasing) + + # 圆形背景 + p.setPen(Qt.NoPen) # type: ignore[attr-defined] + p.setBrush(QBrush(QColor(color))) + p.drawEllipse(pix.rect().adjusted(3, 3, -3, -3)) + + # 符号文字 + pen = QPen(QColor("white"), 2) + p.setPen(pen) + 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.end() + + return QIcon(pix) + + +class WindowMode(str): + """窗口加载方式枚举""" + TAB = "tab" # 标签页内加载 + DETACHED = "detached" # 独立窗口加载 + CLOSED = "closed" # 不自动加载 + + @classmethod + def _values(cls) -> list[str]: + return [cls.TAB, cls.DETACHED, cls.CLOSED] + + # 用于 QComboBox 的显示标签映射 + LABELS = { + TAB: "标签页内", + DETACHED: "独立窗口", + CLOSED: "不自动加载", + } + + +class LogLevel(str): + """日志级别枚举""" + TRACE = "TRACE" + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + + @classmethod + def _values(cls) -> list[str]: + return [cls.TRACE, cls.DEBUG, cls.INFO, cls.WARNING, cls.ERROR] + + # 用于 QComboBox 的显示标签(中文友好) + LABELS = { + TRACE: "TRACE (最详细)", + DEBUG: "DEBUG", + INFO: "INFO (常规)", + WARNING: "WARNING", + ERROR: "ERROR (仅错误)", + } + + +@dataclass +class PluginInfo: + """插件元信息""" + name: str # 插件名称 + version: str = "1.0.0" # 版本号 + author: str = "" # 作者 + description: str = "" # 描述 + enabled: bool = True # 是否启用 + priority: int = 100 # 优先级(数值越小越先执行) + show_window: bool = True # 初始化时是否显示窗口 + window_mode: WindowMode = cast(WindowMode, "tab") # 窗口加载方式 + log_level: LogLevel = cast(LogLevel, "DEBUG") # 默认日志级别 + icon: QIcon | None = None # 插件图标,None 使用默认蓝色问号 + log_config: "LogConfig | None" = None # 日志轮转配置,None 使用全局默认值 + + +class BasePlugin(ABC): + """ + 插件基类 + + 每个插件同时具备后台数据处理和界面交互能力: + - 后台部分:订阅事件、处理数据、发送控制指令 + - 界面部分:可选的 PyQt 界面组件 + + 所有插件共享同一个 ZMQClient,事件通过 EventDispatcher 内部分发。 + + 子类必须实现 ``plugin_info()`` 类方法来声明元信息:: + + class MyPlugin(BasePlugin): + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + description="我的插件", + icon=make_plugin_icon("#e65100", "📝"), + ) + """ + + @classmethod + @abstractmethod + def plugin_info(cls) -> PluginInfo: + """返回插件元信息。子类必须重写此方法。""" + + def __init__(self, info: PluginInfo): + self._info = info + self._client: ZMQClient | None = None + self._event_dispatcher: EventDispatcher | None = None + self._widget: QWidget | None = None + self._initialized = False + + # 每个插件拥有独立的 loguru logger(日志写入 plugins/.log) + from .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 # 当前日志级别 + + # ═══════════════════════════════════════════════════════════════════ + # 属性 + # ═══════════════════════════════════════════════════════════════════ + + @property + def info(self) -> PluginInfo: + return self._info + + @property + def name(self) -> str: + return self._info.name + + @property + def is_enabled(self) -> bool: + return self._info.enabled + + @property + def widget(self) -> QWidget | None: + return self._widget + + @property + def client(self) -> ZMQClient | None: + return self._client + + @property + def log_level(self) -> LogLevel: + """当前日志级别""" + return self._log_level + + def set_log_level(self, level: LogLevel | str) -> None: + """ + 动态设置插件的日志级别 + + Args: + level: 日志级别 (LogLevel 枚举或字符串 "TRACE"/"DEBUG" 等) + """ + from .logging_setup import set_plugin_log_level + if isinstance(level, str): + level = LogLevel(level.upper()) + self._log_level = level + set_plugin_log_level(self._log_sink_id, level) + self.logger.debug("Log level changed to %s", level) + + @property + def plugin_icon(self) -> QIcon: + """返回插件图标(使用 PluginInfo.icon,未设置则生成默认图标)""" + if self._info.icon: + return self._info.icon + return make_plugin_icon() + + # ═══════════════════════════════════════════════════════════════════ + # 生命周期 + # ═══════════════════════════════════════════════════════════════════ + + def set_client(self, client: ZMQClient) -> None: + self._client = client + + def set_event_dispatcher(self, dispatcher: EventDispatcher) -> None: + self._event_dispatcher = dispatcher + + def initialize(self) -> None: + """初始化插件""" + if self._initialized: + return + + self._setup_subscriptions() + self._widget = self._create_widget() + self._initialized = True + self.on_initialized() + + def shutdown(self) -> None: + """关闭插件""" + if not self._initialized: + return + + self.on_shutdown() + + if self._event_dispatcher: + self._event_dispatcher.unsubscribe_all(self) + + if self._widget: + self._widget.deleteLater() + self._widget = None + + self._initialized = False + + # ═══════════════════════════════════════════════════════════════════ + # 抽象方法 + # ═══════════════════════════════════════════════════════════════════ + + @abstractmethod + def _setup_subscriptions(self) -> None: + """ + 设置事件订阅 + + 子类实现此方法,订阅感兴趣的事件: + self.subscribe(GameStartedEvent, self._on_game_started) + self.subscribe(BoardUpdateEvent, self._on_board_update) + """ + pass + + # ═══════════════════════════════════════════════════════════════════ + # 可选重写 + # ═══════════════════════════════════════════════════════════════════ + + def _create_widget(self) -> QWidget | None: + """创建界面组件,返回 None 表示无界面""" + return None + + def on_initialized(self) -> None: + """插件初始化完成回调""" + pass + + def on_shutdown(self) -> None: + """插件关闭前回调""" + pass + + # ═══════════════════════════════════════════════════════════════════ + # 事件订阅(使用事件类) + # ═══════════════════════════════════════════════════════════════════ + + def subscribe( + self, + event_class: type[_E], + handler: Callable[[_E], None], + ) -> None: + """ + 订阅事件 + + Args: + event_class: 事件类(如 GameStartedEvent) + handler: 事件处理函数,参数类型必须与 event_class 一致 + """ + if self._event_dispatcher: + tag = get_event_tag(event_class) + self._event_dispatcher.subscribe(tag, handler, self._info.priority, self) + + def unsubscribe(self, event_class: type[BaseEvent]) -> None: + """取消订阅事件""" + if self._event_dispatcher: + tag = get_event_tag(event_class) + self._event_dispatcher.unsubscribe(tag, self) + + # ═══════════════════════════════════════════════════════════════════ + # 指令发送 + # ═══════════════════════════════════════════════════════════════════ + + def send_command(self, command: Any) -> None: + """发送控制指令到主进程(异步)""" + if self._client: + self._client.send_command(command) + + def request(self, command: Any, timeout: float = 5.0) -> Any: + """发送请求并等待响应(同步)""" + if self._client: + return self._client.request(command, timeout) + return None + + # ═══════════════════════════════════════════════════════════════════ + # 辅助 + # ═══════════════════════════════════════════════════════════════════ + + def enable(self) -> None: + """启用插件""" + self._info.enabled = True + if not self._initialized: + self.initialize() + + def disable(self) -> None: + """禁用插件""" + self._info.enabled = False + if self._initialized: + self.shutdown() + + def __repr__(self) -> str: + return f"" diff --git a/src/plugin_manager/plugin_loader.py b/src/plugin_manager/plugin_loader.py new file mode 100644 index 0000000..1f4977d --- /dev/null +++ b/src/plugin_manager/plugin_loader.py @@ -0,0 +1,169 @@ +""" +插件加载器 + +支持动态导入插件模块,从指定目录发现和加载插件 +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import loguru + +if TYPE_CHECKING: + from .plugin_base import BasePlugin, PluginInfo + +logger = loguru.logger.bind(name="PluginLoader") + + +class PluginLoader: + """ + 插件加载器 + + 功能: + - 从目录发现插件模块 + - 动态导入插件 + - 实例化插件类 + """ + + def __init__(self, plugin_dirs: list[str | Path] | None = None): + self._plugin_dirs: list[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) + 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 = [] + + 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}") + + 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) + 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) + return None + + def get_plugin_classes(self, module: object) -> list[type[BasePlugin]]: + """从模块中提取插件类""" + from .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) + and issubclass(obj, BasePlugin) + and obj is not 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, + module_name: str, + ) -> 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) + plugin = plugin_class(info) + plugins.append(plugin) + logger.info(f"Instantiated plugin: {plugin.name}") + except Exception as e: + logger.error( + 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 diff --git a/src/plugin_manager/plugin_manager.py b/src/plugin_manager/plugin_manager.py new file mode 100644 index 0000000..d4c6e41 --- /dev/null +++ b/src/plugin_manager/plugin_manager.py @@ -0,0 +1,327 @@ +""" +插件管理器主类 + +作为独立进程运行,管理所有插件的加载、生命周期和通信 +所有插件共享同一个 ZMQClient,事件通过 EventDispatcher 内部分发 +""" +from __future__ import annotations + +import threading +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import loguru +from lib_zmq_plugins.client.zmq_client import ZMQClient +from lib_zmq_plugins.log import LogHandler +from lib_zmq_plugins.shared.base import BaseEvent, get_event_tag + +from shared_types import EVENT_TYPES, COMMAND_TYPES + +from .event_dispatcher import EventDispatcher +from .plugin_base import BasePlugin +from .plugin_loader import PluginLoader +from .app_paths import get_all_plugin_dirs, patch_sys_path_for_frozen + +if TYPE_CHECKING: + from PyQt5.QtWidgets import QApplication, QWidget + +logger = loguru.logger.bind(name="PluginManager") + + +class PluginManager: + """ + 插件管理器 + + 核心设计: + - 所有插件共享一个 ZMQClient + - 事件通过 EventDispatcher 内部分发给各插件 + - 支持动态加载插件 + - 拥有独立的 PyQt 主窗口 + """ + + def __init__( + self, + endpoint: str, + plugin_dirs: list[str | Path] | None = None, + log_handler: LogHandler | None = None, + ): + self._endpoint = endpoint + self._log = log_handler + + # 打包模式路径补丁 + patch_sys_path_for_frozen() + + # 默认插件目录 + if plugin_dirs is None: + plugin_dirs = get_all_plugin_dirs() + + # 共享的 Client + self._client = ZMQClient( + endpoint=endpoint, + on_connected=self._on_connected, + on_disconnected=self._on_disconnected, + log_handler=log_handler, + ) + self._dispatcher = EventDispatcher() + self._loader = PluginLoader(plugin_dirs) + + # 插件管理 + self._plugins: dict[str, BasePlugin] = {} + self._plugins_lock = threading.RLock() + + # 主窗口 + self._main_window = None + self._app = None + + # 注册类型 + self._client.register_event_types(*EVENT_TYPES) + self._client.register_command_types(*COMMAND_TYPES) + + self._started = False + + # ═══════════════════════════════════════════════════════════════════ + # 属性 + # ═══════════════════════════════════════════════════════════════════ + + @property + def client(self) -> ZMQClient: + return self._client + + @property + def dispatcher(self) -> EventDispatcher: + return self._dispatcher + + @property + def plugins(self) -> dict[str, BasePlugin]: + return self._plugins.copy() + + @property + def main_window(self): + return self._main_window + + @property + def is_connected(self) -> bool: + """当前连接状态""" + return self._client.is_connected + + @property + def reconnect_count(self) -> int: + """重连次数""" + return self._client.reconnect_count + + @property + def connection_endpoint(self) -> str: + """连接端点地址""" + return self._endpoint + + # ═══════════════════════════════════════════════════════════════════ + # 生命周期 + # ═══════════════════════════════════════════════════════════════════ + + def start(self) -> None: + """启动插件管理器(后台模式,无界面)""" + if self._started: + return + + self._load_plugins() + self._client.connect() + self._setup_zmq_subscriptions() + self._initialize_plugins() + + self._started = True + logger.info("Plugin manager started") + + def stop(self) -> None: + """停止插件管理器""" + if not self._started: + return + + self._shutdown_plugins() + self._client.disconnect() + self._dispatcher.clear() + + self._started = False + logger.info("Plugin manager stopped") + + def start_with_gui(self, app: QApplication = None, *, show_main_window: bool = True) -> None: + """ + 启动插件管理器并显示主界面 + + Args: + app: QApplication 实例,如果不提供则创建新的 + show_main_window: 是否显示主窗口(False 时仅在托盘运行) + """ + from PyQt5.QtWidgets import QApplication + from .main_window import PluginManagerWindow + + # 创建或使用现有的 QApplication + if app is None: + app = QApplication.instance() + if app is None: + app = QApplication([]) + + self._app = app + + # 启动核心功能 + self.start() + + # 创建主窗口(始终创建以支持托盘图标) + self._main_window = PluginManagerWindow(self) + self._main_window.setWindowTitle(f"插件管理器 - {self._endpoint}") + if show_main_window: + self._main_window.show() + + logger.info("Plugin manager started with GUI (window=%s)", show_main_window) + + def exec_gui(self, *, show_main_window: bool = True) -> int: + """ + 启动 GUI 事件循环 + + Args: + show_main_window: 是否显示主窗口(False 时仅在托盘运行) + + Returns: + 退出代码 + """ + if self._app is None: + self.start_with_gui(show_main_window=show_main_window) + + result = self._app.exec_() + self.stop() + return result + + # ═══════════════════════════════════════════════════════════════════ + # ZMQ 订阅(使用事件类) + # ═══════════════════════════════════════════════════════════════════ + + def _setup_zmq_subscriptions(self) -> None: + """设置 ZMQ 事件订阅""" + for event_type in EVENT_TYPES: + tag = get_event_tag(event_type) + # 订阅 ZMQ 事件,收到后分发给内部插件 + self._client.subscribe( + event_type, + lambda event, t=tag: self._dispatcher.dispatch(t, event), + ) + + # ═══════════════════════════════════════════════════════════════════ + # 插件管理 + # ═══════════════════════════════════════════════════════════════════ + + def add_plugin_dir(self, path: str | Path) -> None: + self._loader.add_plugin_dir(path) + + def _load_plugins(self) -> None: + plugins = self._loader.load_all() + + with self._plugins_lock: + for plugin in plugins: + self._plugins[plugin.name] = plugin + plugin.set_client(self._client) + plugin.set_event_dispatcher(self._dispatcher) + + logger.info(f"Loaded {len(plugins)} plugin(s)") + + def _initialize_plugins(self) -> None: + with self._plugins_lock: + for plugin in self._plugins.values(): + try: + if plugin.is_enabled: + plugin.initialize() + logger.info(f"Initialized plugin: {plugin.name}") + except Exception as e: + logger.error(f"Failed to initialize plugin {plugin.name}: {e}", exc_info=True) + + def _shutdown_plugins(self) -> None: + with self._plugins_lock: + for plugin in self._plugins.values(): + try: + plugin.shutdown() + logger.info(f"Shutdown plugin: {plugin.name}") + except Exception as e: + logger.error(f"Failed to shutdown plugin {plugin.name}: {e}", exc_info=True) + + def get_plugin(self, name: str) -> BasePlugin | None: + return self._plugins.get(name) + + def enable_plugin(self, name: str) -> bool: + plugin = self._plugins.get(name) + if plugin: + plugin.enable() + return True + return False + + def disable_plugin(self, name: str) -> bool: + plugin = self._plugins.get(name) + if plugin: + plugin.disable() + return True + return False + + # ═══════════════════════════════════════════════════════════════════ + # 界面管理 + # ═══════════════════════════════════════════════════════════════════ + + def get_plugin_widgets(self) -> dict[str, QWidget]: + widgets = {} + with self._plugins_lock: + for name, plugin in self._plugins.items(): + if plugin.widget: + widgets[name] = plugin.widget + return widgets + + # ═══════════════════════════════════════════════════════════════════ + # ZMQ 回调 + # ═══════════════════════════════════════════════════════════════════ + + def _on_connected(self) -> None: + logger.info("Connected to main process") + if self._main_window: + self._main_window.set_connected(True) + + def _on_disconnected(self) -> None: + logger.warning("Disconnected from main process") + if self._main_window: + self._main_window.set_connected(False) + + def __repr__(self) -> str: + return f"" + + +def run_plugin_manager_process( + endpoint: str, + plugin_dirs: list[str] | None = None, + with_gui: bool = True, + show_main_window: bool = True, +) -> int: + """ + 在独立进程中运行插件管理器 + + Args: + endpoint: ZMQ Server 地址 + plugin_dirs: 插件目录列表 + with_gui: 是否显示界面(False 为完全无界面后台模式) + show_main_window: 是否显示主窗口(False 时 GUI 仅显示托盘图标) + + Returns: + 退出代码 + """ + manager = PluginManager( + endpoint=endpoint, + plugin_dirs=plugin_dirs, + ) + + try: + if with_gui: + return manager.exec_gui(show_main_window=show_main_window) + else: + manager.start() + while True: + import time + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + manager.stop() + + return 0 diff --git a/src/plugin_manager/plugin_state.py b/src/plugin_manager/plugin_state.py new file mode 100644 index 0000000..15e6043 --- /dev/null +++ b/src/plugin_manager/plugin_state.py @@ -0,0 +1,114 @@ +""" +插件状态持久化 + +将每个插件的 UI 状态保存到 JSON 文件,包括: +- 是否启用 +- 初始化时是否加载窗口 +- 加载到标签页 / 独立窗口 / 不加载 +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Any + +import loguru +from .plugin_base import WindowMode, LogLevel + +logger = loguru.logger.bind(name="PluginState") + + +@dataclass +class PluginState: + """单个插件的持久化状态""" + enabled: bool = True # 是否启用 + show_window: bool = True # 是否在初始化时显示窗口 + window_mode: WindowMode = WindowMode.TAB # 窗口加载方式 + log_level: LogLevel = LogLevel.DEBUG # 日志级别 + + +# 默认状态,插件第一次出现时使用 +_DEFAULT = PluginState() + + +class PluginStateManager: + """管理所有插件状态的读写""" + + def __init__(self, file_path: str | Path): + self._file = Path(file_path) + self._states: dict[str, PluginState] = {} + self._dirty = False + + # ── 读写 ──────────────────────────────────────────── + + def load(self) -> None: + """从 JSON 文件加载状态""" + if not self._file.exists(): + logger.info("State file not found: %s (will create on save)", self._file) + return + try: + 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)}) + logger.info("Loaded state for %d plugin(s)", len(self._states)) + except Exception as e: + logger.error("Failed to load state from %s: %s", self._file, e) + + def save(self) -> None: + """写入 JSON 文件(仅在有变更时)""" + if not self._dirty: + return + try: + self._file.parent.mkdir(parents=True, exist_ok=True) + raw = {name: asdict(st) for name, st in self._states.items()} + self._file.write_text( + json.dumps(raw, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + self._dirty = False + logger.info("Saved state for %d plugin(s)", len(self._states)) + except Exception as e: + logger.error("Failed to save state to %s: %s", self._file, e) + + # ── 查询 / 修改 ───────────────────────────────────── + + def get(self, name: str) -> PluginState: + """获取某个插件的状态(不存在则返回系统默认值)""" + return self._states.get(name, _DEFAULT) + + def get_effective(self, name: str, plugin_default: PluginState | None = None) -> PluginState: + """ + 获取有效状态(优先级链:JSON 覆盖 > 插件声明 > 系统默认) + + Args: + name: 插件名 + plugin_default: 插件自身声明的默认值(来自 PluginInfo),为 None 时使用系统默认 + """ + if name in self._states: + # JSON 中有记录 → 以 JSON 为准,缺失字段回退到插件/系统默认 + saved = self._states[name] + fallback = plugin_default or _DEFAULT + return PluginState( + enabled=saved.enabled if saved.enabled != _DEFAULT.enabled else fallback.enabled, + show_window=saved.show_window if saved.show_window != _DEFAULT.show_window else fallback.show_window, + window_mode=saved.window_mode if saved.window_mode != _DEFAULT.window_mode else fallback.window_mode, + log_level=saved.log_level if saved.log_level != _DEFAULT.log_level else fallback.log_level, + ) + # 无 JSON 记录 → 使用插件声明或系统默认 + return (plugin_default or _DEFAULT) + + def set(self, name: str, state: PluginState) -> None: + """更新某个插件的状态(标记为脏)""" + self._states[name] = state + self._dirty = True + + def remove(self, name: str) -> None: + """移除某条记录""" + if name in self._states: + del self._states[name] + self._dirty = True + + @property + def all_states(self) -> dict[str, PluginState]: + return dict(self._states) diff --git a/src/plugin_manager/server_bridge.py b/src/plugin_manager/server_bridge.py new file mode 100644 index 0000000..986bd64 --- /dev/null +++ b/src/plugin_manager/server_bridge.py @@ -0,0 +1,113 @@ +""" +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 ( + GameStartedEvent, + GameEndedEvent, + 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 publish_game_started(self, rows: int, cols: int, mines: int) -> None: + """发布游戏开始事件""" + event = GameStartedEvent(rows=rows, cols=cols, mines=mines) + self._server.publish(GameStartedEvent, event) + + def publish_game_ended(self, is_win: bool, time: float) -> None: + """发布游戏结束事件""" + event = GameEndedEvent(is_win=is_win, time=time) + self._server.publish(GameEndedEvent, event) + + def publish_board_update(self, board: list[list[int]]) -> None: + """发布局面刷新事件""" + event = BoardUpdateEvent(board=board) + self._server.publish(BoardUpdateEvent, event) + + # ═══════════════════════════════════════════════════════════════════ + # 指令处理 + # ═══════════════════════════════════════════════════════════════════ + + 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/plugins/History/History.py b/src/plugins/History/History.py deleted file mode 100644 index 2e35a9f..0000000 --- a/src/plugins/History/History.py +++ /dev/null @@ -1,225 +0,0 @@ -import base64 -import sys -import os -import msgspec -import zmq -import sqlite3 - - -if getattr(sys, "frozen", False): # 检查是否为pyInstaller生成的EXE - application_path = os.path.dirname(sys.executable) - sys.path.append(application_path + "/../../") - print(application_path + "/../../") -else: - sys.path.append(os.path.dirname(os.path.abspath(__file__)) + "/../../") -from mp_plugins import BasePlugin, BaseConfig -from mp_plugins.base.config import * -from mp_plugins.context import AppContext -from mp_plugins.events import * - - -class HistoryConfig(BaseConfig): - save_mode: SelectSetting - - -class History(BasePlugin): - def __init__( - self, - ) -> None: - super().__init__() - self._context: AppContext - self._config = HistoryConfig( - save_mode=SelectSetting( - "保存模式", "仅保存胜利局", options=["仅保存胜利局"] - ) - ) - - def build_plugin_context(self) -> None: - self._plugin_context.name = "History" - self._plugin_context.display_name = "历史记录" - self._plugin_context.version = "1.0.0" - self._plugin_context.description = "History" - self._plugin_context.author = "ljzloser" - - @property - def db_path(self): - return self.path.parent.parent / "history.db" - - def initialize(self) -> None: - if not self.db_path.exists(): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - cursor.execute( - """ -create table history -( - replay_id INTEGER primary key, - 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 INTEGER, - 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() - return super().initialize() - - def shutdown(self) -> None: - return super().shutdown() - - @BasePlugin.event_handler(GameEndEvent) - def on_game_end(self, event: GameEndEvent): - data = msgspec.structs.asdict(event) - data["raw_data"] = base64.b64decode(s=event.raw_data) - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute( - """ -insert into main.history -( - game_board_state, - rtime, - left, - right, - double, - left_s, - right_s, - double_s, - level, - cl, - cl_s, - ce, - ce_s, - rce, - lce, - dce, - bbbv, - bbbv_solved, - bbbv_s, - flag, - path, - etime, - start_time, - end_time, - mode, - software, - player_identifier, - race_identifier, - uniqueness_identifier, - stnb, - corr, - thrp, - ioe, - is_official, - is_fair, - op, - isl, - pluck, - raw_data - ) -values -( - :game_board_state, - :rtime, - :left, - :right, - :double, - :left_s, - :right_s, - :double_s, - :level, - :cl, - :cl_s, - :ce, - :ce_s, - :rce, - :lce, - :dce, - :bbbv, - :bbbv_solved, - :bbbv_s, - :flag, - :path, - :etime, - :start_time, - :end_time, - :mode, - :software, - :player_identifier, - :race_identifier, - :uniqueness_identifier, - :stnb, - :corr, - :thrp, - :ioe, - :is_official, - :is_fair, - :op, - :isl, - :pluck, - :raw_data - ) - """, - data) - conn.commit() - conn.close() - return event - - -if __name__ == "__main__": - try: - import sys - - args = sys.argv[1:] - host = args[0] - port = int(args[1]) - plugin = History() - # 捕获退出信号,优雅关闭 - import signal - - def signal_handler(sig, frame): - plugin.stop() - sys.exit(0) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - plugin.run(host, port) - except Exception: - pass diff --git a/src/plugins/UpLoadVideo/UpLoadVideo.py b/src/plugins/UpLoadVideo/UpLoadVideo.py deleted file mode 100644 index 09ff2c6..0000000 --- a/src/plugins/UpLoadVideo/UpLoadVideo.py +++ /dev/null @@ -1,75 +0,0 @@ -import sys -import os -import time -import msgspec -import zmq - -if getattr(sys, "frozen", False): # 检查是否为pyInstaller生成的EXE - application_path = os.path.dirname(sys.executable) - sys.path.append(application_path + "/../../") - print(application_path + "/../../") -else: - sys.path.append(os.path.dirname(os.path.abspath(__file__)) + "/../../") -from mp_plugins import BasePlugin, BaseConfig -from mp_plugins.base.config import * -from mp_plugins.context import AppContext -from mp_plugins.events import GameEndEvent - - -class UpLoadVideoConfig(BaseConfig): - user: TextSetting - passwd: TextSetting - upload_circle: NumberSetting - auto_upload: BoolSetting - upload_type: SelectSetting - - -class UpLoadVideo(BasePlugin): - def __init__( - self, - ) -> None: - super().__init__() - self._context: AppContext - self._config = UpLoadVideoConfig( - TextSetting("用户名", "user"), - TextSetting("密码", "passwd"), - NumberSetting(name="上传周期", value=0, min_value=1, - max_value=10, step=1), - BoolSetting("自动上传", True), - SelectSetting("上传类型", "自动上传", options=["自动上传", "手动上传"]), - ) - - def build_plugin_context(self) -> None: - self._plugin_context.name = "UpLoadVideo" - self._plugin_context.display_name = "上传录像" - self._plugin_context.version = "1.0.0" - self._plugin_context.description = "上传录像" - self._plugin_context.author = "LjzLoser" - - def initialize(self) -> None: - return super().initialize() - - def shutdown(self) -> None: - return super().shutdown() - - -if __name__ == "__main__": - try: - import sys - - args = sys.argv[1:] - host = args[0] - port = int(args[1]) - plugin = UpLoadVideo() - # 捕获退出信号,优雅关闭 - import signal - - def signal_handler(sig, frame): - plugin.stop() - sys.exit(0) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - plugin.run(host, port) - except Exception: - pass diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py new file mode 100644 index 0000000..09705a2 --- /dev/null +++ b/src/plugins/__init__.py @@ -0,0 +1,5 @@ +""" +示例插件目录 + +将插件放在此目录下,插件管理器会自动发现和加载。 +""" diff --git a/src/plugins/event_log.py b/src/plugins/event_log.py new file mode 100644 index 0000000..c91fc2c --- /dev/null +++ b/src/plugins/event_log.py @@ -0,0 +1,126 @@ +""" +示例插件:事件日志 + +功能: +- 记录所有收到的游戏事件 +- 界面显示事件时间线 +""" + +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon +from shared_types import ( + GameStartedEvent, + GameEndedEvent, + BoardUpdateEvent, +) +from PyQt5.QtWidgets import ( + QWidget, + QVBoxLayout, + QLabel, + QGroupBox, + QTextEdit, + QTableWidget, + QTableWidgetItem, +) +from PyQt5.QtCore import Qt, QDateTime +from PyQt5.QtGui import QColor + + +class EventLogPlugin(BasePlugin): + """事件日志插件""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="event_log", + description="事件日志记录器", + icon=make_plugin_icon("#e65100", "📝"), + ) + + def __init__(self, info): + super().__init__(info) + + def _setup_subscriptions(self) -> None: + self.subscribe(GameStartedEvent, self._on_game_started) + self.subscribe(GameEndedEvent, self._on_game_ended) + self.subscribe(BoardUpdateEvent, self._on_board_update) + + def _create_widget(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + # 事件表格 + group = QGroupBox("事件流") + glayout = QVBoxLayout(group) + + self._table = QTableWidget() + self._table.setColumnCount(3) + self._table.setHorizontalHeaderLabels(["时间", "类型", "详情"]) + self._table.setEditTriggers(QTableWidget.NoEditTriggers) + self._table.setSelectionMode(QTableWidget.NoSelection) + self._table.horizontalHeader().setStretchLastSection(True) + self._table.verticalHeader().setVisible(False) + glayout.addWidget(self._table) + layout.addWidget(group) + + # 统计 + sgroup = QGroupBox("统计") + slayout = QVBoxLayout(sgroup) + + self._stats_label = QLabel("等待事件...") + slayout.addWidget(self._stats_label) + layout.addWidget(sgroup) + + return widget + + def _add_event(self, event_type: str, detail: str, color: str | None = None) -> None: + if not hasattr(self, "_table"): + return + + row = self._table.rowCount() + self._table.insertRow(row) + + time_item = QTableWidgetItem( + QDateTime.currentDateTime().toString("HH:mm:ss.zzz") + ) + type_item = QTableWidgetItem(event_type) + detail_item = QTableWidgetItem(detail) + + if color: + for item in (time_item, type_item, detail_item): + item.setForeground(QColor(color)) + + self._table.setItem(row, 0, time_item) + self._table.setItem(row, 1, type_item) + self._table.setItem(row, 2, detail_item) + + self._table.scrollToBottom() + + def _update_stats(self): + total = self._table.rowCount() + if hasattr(self, "_stats_label"): + self._stats_label.setText(f"已记录 {total} 条事件") + + def _on_game_started(self, event: GameStartedEvent) -> None: + msg = f"{event.rows}x{event.cols}, {event.mines}雷" + self._add_event("GameStarted", msg, "#1976d2") + self.logger.info(f"GameStarted: {msg}") + self._update_stats() + + def _on_game_ended(self, event: GameEndedEvent) -> None: + result = "胜利" if event.is_win else "失败" + color = "#2e7d32" if event.is_win else "#c62828" + msg = f"{result}, 用时 {event.time:.3f}s" + self._add_event("GameEnded", msg, color) + self.logger.info(f"GameEnded: {msg}") + self._update_stats() + + def _on_board_update(self, event: BoardUpdateEvent) -> None: + rows = len(event.board) + cols = len(event.board[0]) if rows else 0 + msg = f"{rows}x{cols}" + self._add_event("BoardUpdate", msg, "#757575") + self.logger.debug(f"BoardUpdate: {msg}") + self._update_stats() + + def on_initialized(self) -> None: + self.logger.info("事件日志插件已初始化") diff --git a/src/plugins/game_monitor.py b/src/plugins/game_monitor.py new file mode 100644 index 0000000..0d80eed --- /dev/null +++ b/src/plugins/game_monitor.py @@ -0,0 +1,270 @@ +""" +示例插件:简单游戏监控插件 + +功能: +- 监听游戏开始、局面刷新、游戏结束事件 +- 界面显示局面网格 +- 按钮控制新游戏 +""" + +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon +from shared_types import ( + GameStartedEvent, + GameEndedEvent, + BoardUpdateEvent, + NewGameCommand, +) +from PyQt5.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSpinBox, + QGroupBox, + QTextEdit, + QTableWidget, + QTableWidgetItem, + QHeaderView, +) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor, QFont + + +class GameMonitorPlugin(BasePlugin): + """游戏监控插件""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="game_monitor", + description="游戏监控插件", + icon=make_plugin_icon("#1976d2", "🎮"), + ) + + # 局面值的颜色映射 + CELL_COLORS = { + 0: "#C0C0C0", # 空 + 1: "#0000FF", # 1 - 蓝 + 2: "#008000", # 2 - 绿 + 3: "#FF0000", # 3 - 红 + 4: "#000080", # 4 - 深蓝 + 5: "#800000", # 5 - 棕 + 6: "#008080", # 6 - 青 + 7: "#000000", # 7 - 黑 + 8: "#808080", # 8 - 灰 + 10: "#C0C0C0", # 未打开 + 11: "#C0C0C0", # 标雷 + 14: "#FF0000", # 踩雷(叉雷) + 15: "#FF0000", # 踩雷(红雷) + 16: "#FFFFFF", # 白雷 + } + + def __init__(self, info): + super().__init__(info) + self._game_rows = 0 + self._game_cols = 0 + self._game_mines = 0 + self._board = [] + + def _setup_subscriptions(self) -> None: + self.subscribe(GameStartedEvent, self._on_game_started) + self.subscribe(GameEndedEvent, self._on_game_ended) + self.subscribe(BoardUpdateEvent, self._on_board_update) + + def _create_widget(self): + """创建界面""" + + widget = QWidget() + layout = QVBoxLayout(widget) + + # 状态显示区 + status_group = QGroupBox("游戏状态") + status_layout = QVBoxLayout(status_group) + + self._status_label = QLabel("等待游戏...") + self._status_label.setStyleSheet("font-size: 14px; padding: 5px;") + status_layout.addWidget(self._status_label) + + self._info_label = QLabel("") + self._info_label.setStyleSheet("color: gray;") + status_layout.addWidget(self._info_label) + + layout.addWidget(status_group) + + # 局面显示区 + board_group = QGroupBox("局面") + board_layout = QVBoxLayout(board_group) + + self._board_table = QTableWidget() + self._board_table.setMinimumHeight(200) + self._board_table.setEditTriggers(QTableWidget.NoEditTriggers) + self._board_table.setSelectionMode(QTableWidget.NoSelection) + self._board_table.setFocusPolicy(Qt.NoFocus) + board_layout.addWidget(self._board_table) + + layout.addWidget(board_group) + + # 控制区 + control_group = QGroupBox("新游戏控制") + control_layout = QVBoxLayout(control_group) + + # 行列雷数设置 + param_layout = QHBoxLayout() + + param_layout.addWidget(QLabel("行:")) + self._rows_spin = QSpinBox() + self._rows_spin.setRange(1, 100) + self._rows_spin.setValue(16) + param_layout.addWidget(self._rows_spin) + + param_layout.addWidget(QLabel("列:")) + self._cols_spin = QSpinBox() + self._cols_spin.setRange(1, 100) + self._cols_spin.setValue(30) + param_layout.addWidget(self._cols_spin) + + param_layout.addWidget(QLabel("雷:")) + self._mines_spin = QSpinBox() + self._mines_spin.setRange(1, 999) + self._mines_spin.setValue(99) + param_layout.addWidget(self._mines_spin) + + control_layout.addLayout(param_layout) + + # 按钮 + self._new_game_btn = QPushButton("开始新游戏") + self._new_game_btn.clicked.connect(self._on_new_game_clicked) + control_layout.addWidget(self._new_game_btn) + + layout.addWidget(control_group) + + # 日志区 + log_group = QGroupBox("事件日志") + log_layout = QVBoxLayout(log_group) + + self._log_text = QTextEdit() + self._log_text.setReadOnly(True) + self._log_text.setMaximumHeight(100) + log_layout.addWidget(self._log_text) + + layout.addWidget(log_group) + + return widget + + def _log(self, msg: str) -> None: + """添加日志并滚动到底部""" + if self._log_text: + self._log_text.append(msg) + # 滚动到最后一行 + scrollbar = self._log_text.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def _render_board(self) -> None: + """渲染局面""" + if not self._board_table or not self._board: + return + + rows = len(self._board) + cols = len(self._board[0]) if rows > 0 else 0 + + self._board_table.setRowCount(rows) + self._board_table.setColumnCount(cols) + + # 计算单元格大小 + cell_size = max(12, min(25, 400 // max(rows, cols))) + + for i in range(rows): + self._board_table.setRowHeight(i, cell_size) + for j in range(cols): + val = self._board[i][j] if j < len(self._board[i]) else 10 + item = QTableWidgetItem() + + # 设置文字 + if val == 0: + item.setText("") + elif 1 <= val <= 8: + item.setText(str(val)) + elif val == 10: + item.setText("") + elif val == 11: + item.setText("🚩") + elif val in (14, 15, 16): + item.setText("💣") + else: + item.setText("") + + # 设置颜色 + color = self.CELL_COLORS.get(val, "#C0C0C0") + item.setBackground(QColor(color)) + if val in (1, 4, 7): + item.setForeground(QColor("#0000FF")) + elif val == 2: + item.setForeground(QColor("#008000")) + elif val == 3: + item.setForeground(QColor("#FF0000")) + + item.setTextAlignment(Qt.AlignCenter) + font = QFont() + font.setBold(True) + font.setPointSize(max(6, cell_size // 3)) + item.setFont(font) + + self._board_table.setItem(i, j, item) + + # 设置列宽 + for j in range(cols): + self._board_table.setColumnWidth(j, cell_size) + + # 隐藏表头 + self._board_table.horizontalHeader().hide() + self._board_table.verticalHeader().hide() + + def _on_game_started(self, event: GameStartedEvent) -> None: + self._game_rows = event.rows + self._game_cols = event.cols + self._game_mines = event.mines + self._board = [] + + self._status_label.setText("🎮 游戏进行中") + self._status_label.setStyleSheet("font-size: 14px; padding: 5px; color: green;") + self._info_label.setText(f"局面: {event.rows}x{event.cols}, {event.mines}雷") + msg = f"游戏开始: {event.rows}x{event.cols}, {event.mines}雷" + self._log(f"🎮 {msg}") + self.logger.info(msg) + + def _on_game_ended(self, event: GameEndedEvent) -> None: + result = "🎉 胜利" if event.is_win else "💥 失败" + color = "green" if event.is_win else "red" + + self._status_label.setText(f"{result}! 用时 {event.time:.2f}秒") + self._status_label.setStyleSheet( + f"font-size: 14px; padding: 5px; color: {color};" + ) + msg = f"{result}: 用时 {event.time:.2f}秒" + self._log(msg) + self.logger.info(msg) + + def _on_board_update(self, event: BoardUpdateEvent) -> None: + self._board = event.board + self._render_board() + rows = len(event.board) + msg = f"局面刷新: {rows}行" + self._log(f"📊 {msg}") + self.logger.debug(msg) + + def _on_new_game_clicked(self) -> None: + """新游戏按钮点击""" + rows = self._rows_spin.value() + cols = self._cols_spin.value() + mines = self._mines_spin.value() + + cmd = NewGameCommand(rows=rows, cols=cols, mines=mines) + self.send_command(cmd) + msg = f"发送新游戏指令: {rows}x{cols}, {mines}雷" + self._log(f"📤 {msg}") + self.logger.info(msg) + + def on_initialized(self) -> None: + self._log("✅ 插件已初始化") + self.logger.info("游戏监控插件已初始化") diff --git a/src/plugins/stats.py b/src/plugins/stats.py new file mode 100644 index 0000000..f262875 --- /dev/null +++ b/src/plugins/stats.py @@ -0,0 +1,134 @@ +""" +示例插件:统计面板 + +功能: +- 统计游戏胜率、平均用时 +- 界面显示统计图表和数字 +""" + +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon +from shared_types import ( + GameStartedEvent, + GameEndedEvent, + BoardUpdateEvent, +) +from PyQt5.QtWidgets import ( + QWidget, + QVBoxLayout, + QLabel, + QGroupBox, + QTextEdit, + QTableWidget, + QTableWidgetItem, +) + + +class StatsPlugin(BasePlugin): + """游戏统计插件""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="stats", + description="游戏统计面板", + icon=make_plugin_icon("#7b1fa2", "📊"), + ) + + def __init__(self, info): + super().__init__(info) + self._total_games = 0 + self._wins = 0 + self._losses = 0 + self._total_time = 0.0 + + def _setup_subscriptions(self) -> None: + self.subscribe(GameStartedEvent, self._on_game_started) + self.subscribe(GameEndedEvent, self._on_game_ended) + + def _create_widget(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + # 统计概览 + group = QGroupBox("统计概览") + glayout = QVBoxLayout(group) + + self._summary_label = QLabel("暂无数据") + self._summary_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;") + glayout.addWidget(self._summary_label) + layout.addWidget(group) + + # 详细表格 + tgroup = QGroupBox("历史记录") + tlayout = QVBoxLayout(tgroup) + + self._table = QTableWidget() + self._table.setColumnCount(3) + self._table.setHorizontalHeaderLabels(["结果", "用时(秒)", "备注"]) + self._table.setEditTriggers(QTableWidget.NoEditTriggers) + self._table.setSelectionMode(QTableWidget.NoSelection) + self._table.horizontalHeader().setStretchLastSection(True) + tlayout.addWidget(self._table) + layout.addWidget(tgroup) + + # 日志 + lgroup = QGroupBox("事件日志") + llayout = QVBoxLayout(lgroup) + + self._log_text = QTextEdit() + self._log_text.setReadOnly(True) + self._log_text.setMaximumHeight(100) + llayout.addWidget(self._log_text) + layout.addWidget(lgroup) + + return widget + + def _log(self, msg: str) -> None: + if self._log_text: + self._log_text.append(msg) + sb = self._log_text.verticalScrollBar() + sb.setValue(sb.maximum()) + + def _update_summary(self) -> None: + if not hasattr(self, "_summary_label"): + return + win_rate = (self._wins / self._total_games * 100) if self._total_games else 0 + avg_time = (self._total_time / self._total_games) if self._total_games else 0 + self._summary_label.setText( + f"总计: {self._total_games} 场 | " + f"胜: {self._wins} | " + f"负: {self._losses} | " + f"胜率: {win_rate:.1f}% | " + f"均时: {avg_time:.2f}s" + ) + + def _on_game_started(self, event: GameStartedEvent) -> None: + self._log(f"🎮 游戏开始: {event.rows}x{event.cols}, {event.mines}雷") + self.logger.info(f"游戏开始: {event.rows}x{event.cols}, {event.mines}雷") + + def _on_game_ended(self, event: GameEndedEvent) -> None: + self._total_games += 1 + self._total_time += event.time + result = "胜利" if event.is_win else "失败" + if event.is_win: + self._wins += 1 + else: + self._losses += 1 + + # 添加到表格 + if hasattr(self, "_table"): + row = self._table.rowCount() + self._table.insertRow(row) + self._table.setItem(row, 0, QTableWidgetItem(result)) + self._table.setItem(row, 1, QTableWidgetItem(f"{event.time:.2f}")) + self._table.setItem(row, 2, QTableWidgetItem("" if event.is_win else "踩雷")) + + self._update_summary() + msg = f"{'🎉' if event.is_win else '💥'} {result}! 用时 {event.time:.2f}秒" + self._log(msg) + self.logger.info(f"游戏结束: {result}, 用时 {event.time:.2f}s, " + f"总场次={self._total_games}") + + def on_initialized(self) -> None: + self._log("✅ 统计插件已初始化") + self.logger.info("统计插件已初始化") diff --git a/src/run_plugin_manager.py b/src/run_plugin_manager.py new file mode 100644 index 0000000..9927f1e --- /dev/null +++ b/src/run_plugin_manager.py @@ -0,0 +1,84 @@ +""" +启动插件管理器进程 + +独立进程运行,连接到扫雷主进程 +""" +import sys +import os +import argparse + +# 确保 src 目录在路径中 +src_dir = os.path.dirname(os.path.abspath(__file__)) +if src_dir not in sys.path: + sys.path.insert(0, src_dir) + + +def main(): + parser = argparse.ArgumentParser(description="插件管理器") + parser.add_argument( + "--endpoint", + "-e", + default=None, + help="ZMQ 端点地址(默认 tcp://127.0.0.1:5555)", + ) + parser.add_argument( + "--no-gui", + action="store_true", + help="不显示界面(后台运行)", + ) + parser.add_argument( + "--plugin-dir", + "-p", + action="append", + help="插件目录(可多次指定)", + ) + args = parser.parse_args() + + # 初始化 loguru 日志系统 + from plugin_manager.app_paths import get_log_dir + from plugin_manager.logging_setup import init_logging + init_logging(get_log_dir(), console=True) + + # 确定端点(Windows 不支持 ipc,使用 tcp) + if args.endpoint is None: + endpoint = "tcp://127.0.0.1:5555" + else: + endpoint = args.endpoint + + # 确定插件目录 + plugin_dirs = args.plugin_dir + if plugin_dirs is None: + # 默认插件目录 + plugin_dirs = [os.path.join(src_dir, "plugins")] + + from plugin_manager import PluginManager + + # 创建并启动 + manager = PluginManager( + endpoint=endpoint, + plugin_dirs=plugin_dirs, + ) + + if args.no_gui: + # 后台模式 + try: + manager.start() + print(f"插件管理器已启动: {endpoint}") + print("按 Ctrl+C 停止") + while True: + import time + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + manager.stop() + else: + # GUI 模式 + from PyQt5.QtWidgets import QApplication + app = QApplication(sys.argv) + manager.start_with_gui(app) + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/src/shared_types/__init__.py b/src/shared_types/__init__.py new file mode 100644 index 0000000..e40178c --- /dev/null +++ b/src/shared_types/__init__.py @@ -0,0 +1,27 @@ +""" +共享类型模块 + +定义主进程和插件管理器共用的类型 +""" +from .events import ( + GameStartedEvent, + GameEndedEvent, + BoardUpdateEvent, + EVENT_TYPES, +) + +from .commands import ( + NewGameCommand, + COMMAND_TYPES, +) + +__all__ = [ + # 事件 + "GameStartedEvent", + "GameEndedEvent", + "BoardUpdateEvent", + "EVENT_TYPES", + # 指令 + "NewGameCommand", + "COMMAND_TYPES", +] \ No newline at end of file diff --git a/src/shared_types/commands.py b/src/shared_types/commands.py new file mode 100644 index 0000000..6cb3bcb --- /dev/null +++ b/src/shared_types/commands.py @@ -0,0 +1,18 @@ +""" +扫雷游戏控制指令定义 +""" +from __future__ import annotations + +from lib_zmq_plugins.shared.base import BaseCommand + + +class NewGameCommand(BaseCommand, tag="new_game"): + """新游戏指令""" + rows: int = 16 + cols: int = 30 + mines: int = 99 + + +COMMAND_TYPES = [ + NewGameCommand, +] \ No newline at end of file diff --git a/src/shared_types/events.py b/src/shared_types/events.py new file mode 100644 index 0000000..5db77e2 --- /dev/null +++ b/src/shared_types/events.py @@ -0,0 +1,31 @@ +""" +扫雷游戏事件类型定义 +""" +from __future__ import annotations + +from lib_zmq_plugins.shared.base import BaseEvent + + +class GameStartedEvent(BaseEvent, tag="game_started"): + """游戏开始事件""" + rows: int = 0 + cols: int = 0 + mines: int = 0 + + +class GameEndedEvent(BaseEvent, tag="game_ended"): + """游戏结束事件""" + is_win: bool = False + time: float = 0.0 + + +class BoardUpdateEvent(BaseEvent, tag="board_update"): + """局面刷新事件""" + board: list[list[int]] = [] + + +EVENT_TYPES = [ + GameStartedEvent, + GameEndedEvent, + BoardUpdateEvent, +] \ No newline at end of file