2026-07-20 10:31:10 +08:00
|
|
|
|
"""插件注册中心。
|
|
|
|
|
|
|
|
|
|
|
|
架构说明:
|
|
|
|
|
|
- PluginWorker 运行在独立 QThread 中,仅做纯 I/O(扫描 .pyd、importlib 读元数据、
|
|
|
|
|
|
文件复制),不创建 QObject、不操作 QWidget。
|
|
|
|
|
|
- PluginRegistry 留在主线程,负责 create_plugin()(返回 QWidget,必须在 GUI 线程
|
|
|
|
|
|
创建)与信号转发。
|
|
|
|
|
|
- 跨线程通信全部走 Qt 信号槽(自动 Queued),不再使用 threading.Thread。
|
|
|
|
|
|
"""
|
2026-07-17 15:27:33 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import time
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import importlib
|
|
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from enum import Enum, auto
|
2026-07-20 10:31:10 +08:00
|
|
|
|
from typing import Optional
|
2026-07-17 15:27:33 +08:00
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
from PySide6.QtCore import QObject, Signal, Slot, QThread, QTimer
|
|
|
|
|
|
|
|
|
|
|
|
LOCAL_PLUGINS_PATH = Path("./plugins")
|
2026-07-17 15:27:33 +08:00
|
|
|
|
REMOTE_PLUGINS_PATH = Path('Y:/SE/xufeifei/plugins')
|
2026-07-20 13:15:16 +08:00
|
|
|
|
UNINSTALL_PENDING_FILE = LOCAL_PLUGINS_PATH / ".uninstall_pending"
|
2026-07-17 15:27:33 +08:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 13:15:16 +08:00
|
|
|
|
def _remove_with_retry(target: Path, retries: int = 5, interval: float = 0.2) -> bool:
|
|
|
|
|
|
"""删除文件,失败时短暂重试,应对自重启场景下原进程未完全释放句柄的情况。"""
|
|
|
|
|
|
for _ in range(retries):
|
|
|
|
|
|
try:
|
|
|
|
|
|
target.unlink()
|
|
|
|
|
|
return True
|
|
|
|
|
|
except PermissionError:
|
|
|
|
|
|
time.sleep(interval)
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 15:27:33 +08:00
|
|
|
|
class Event(Enum):
|
|
|
|
|
|
Update = auto()
|
|
|
|
|
|
Install = auto()
|
|
|
|
|
|
Uninstall = auto()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
class PluginWorker(QObject):
|
|
|
|
|
|
"""插件发现与安装 worker,运行在独立 QThread 中。
|
2026-07-17 15:27:33 +08:00
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
仅做纯 I/O,结果以纯数据(list[dict])形式经信号回传主线程。
|
|
|
|
|
|
"""
|
2026-07-17 15:27:33 +08:00
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
local_discovered = Signal(list)
|
|
|
|
|
|
remote_discovered = Signal(list)
|
|
|
|
|
|
install_progress = Signal(int, int)
|
|
|
|
|
|
install_finished = Signal(bool, str)
|
2026-07-20 13:15:16 +08:00
|
|
|
|
uninstall_finished = Signal(str, bool, str)
|
2026-07-17 15:27:33 +08:00
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
@Slot(str)
|
|
|
|
|
|
def do_discover_local(self, plugins_dir: str) -> None:
|
|
|
|
|
|
plugins_dir = Path(plugins_dir)
|
|
|
|
|
|
if not self._ensure_dir(plugins_dir):
|
2026-07-17 15:27:33 +08:00
|
|
|
|
return
|
2026-07-20 10:31:10 +08:00
|
|
|
|
self.local_discovered.emit(self._scan_plugins(plugins_dir))
|
2026-07-17 15:27:33 +08:00
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
@Slot(str)
|
|
|
|
|
|
def do_discover_remote(self, plugins_dir: str) -> None:
|
2026-07-17 15:27:33 +08:00
|
|
|
|
if not os.path.exists(plugins_dir):
|
|
|
|
|
|
logger.error("服务器链接错误!")
|
|
|
|
|
|
return
|
2026-07-20 10:31:10 +08:00
|
|
|
|
self.remote_discovered.emit(
|
|
|
|
|
|
self._scan_plugins(Path(plugins_dir), with_remote_meta=True)
|
|
|
|
|
|
)
|
2026-07-17 15:27:33 +08:00
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
@Slot(str, str)
|
|
|
|
|
|
def do_install(self, local_path: str, remote_path: str) -> None:
|
2026-07-17 15:27:33 +08:00
|
|
|
|
try:
|
|
|
|
|
|
total_size = os.path.getsize(remote_path)
|
|
|
|
|
|
copied_size = 0
|
|
|
|
|
|
with open(remote_path, 'rb') as fsrc, open(local_path, 'wb') as fdst:
|
|
|
|
|
|
while True:
|
2026-07-20 10:31:10 +08:00
|
|
|
|
buf = fsrc.read(1024 * 1024)
|
2026-07-17 15:27:33 +08:00
|
|
|
|
if not buf:
|
|
|
|
|
|
break
|
|
|
|
|
|
fdst.write(buf)
|
|
|
|
|
|
copied_size += len(buf)
|
2026-07-20 10:31:10 +08:00
|
|
|
|
self.install_progress.emit(copied_size, total_size)
|
2026-07-17 15:27:33 +08:00
|
|
|
|
time.sleep(0.1)
|
2026-07-20 10:31:10 +08:00
|
|
|
|
self.install_finished.emit(True, f"{local_path}安装成功!!!")
|
2026-07-17 15:27:33 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"{remote_path}安装失败{e.args}")
|
2026-07-20 10:31:10 +08:00
|
|
|
|
self.install_finished.emit(False, str(e))
|
|
|
|
|
|
|
2026-07-20 13:15:16 +08:00
|
|
|
|
@Slot(str, str)
|
|
|
|
|
|
def do_uninstall(self, name: str, local_path: str) -> None:
|
|
|
|
|
|
"""运行时无法删除已加载的 .pyd(Windows 文件锁),仅记入待删清单。
|
|
|
|
|
|
|
|
|
|
|
|
真正的文件删除由 PluginRegistry._process_pending_uninstalls 在下次启动、
|
|
|
|
|
|
import 之前完成。运行时清理(字典/UI/widget)由主线程在 uninstall_finished
|
|
|
|
|
|
回调中处理。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
tool_name = Path(local_path).stem
|
|
|
|
|
|
UNINSTALL_PENDING_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
with open(UNINSTALL_PENDING_FILE, 'a', encoding='utf-8') as f:
|
|
|
|
|
|
f.write(f"{tool_name}\n")
|
|
|
|
|
|
self.uninstall_finished.emit(name, True, f"{name}已加入卸载清单,重启后生效!!!")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"{local_path}卸载失败{e.args}")
|
|
|
|
|
|
self.uninstall_finished.emit(name, False, str(e))
|
|
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _ensure_dir(plugins_dir: Path) -> bool:
|
|
|
|
|
|
if not os.path.exists(plugins_dir):
|
|
|
|
|
|
os.mkdir(plugins_dir)
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _scan_plugins(plugins_dir: Path, with_remote_meta: bool = False) -> list:
|
|
|
|
|
|
results: list = []
|
|
|
|
|
|
sys.path.append(str(plugins_dir))
|
|
|
|
|
|
try:
|
|
|
|
|
|
for tool in plugins_dir.glob("*.pyd"):
|
|
|
|
|
|
tool_name = tool.stem
|
|
|
|
|
|
try:
|
|
|
|
|
|
module = importlib.import_module(tool_name)
|
|
|
|
|
|
item = {
|
|
|
|
|
|
"tool_name": tool_name,
|
|
|
|
|
|
"name": module.read_plugin_name(),
|
|
|
|
|
|
"version": module.read_plugin_version(),
|
|
|
|
|
|
}
|
|
|
|
|
|
if with_remote_meta:
|
|
|
|
|
|
item["description"] = module.read_plugin_description()
|
|
|
|
|
|
item["remote_path"] = str(plugins_dir)
|
|
|
|
|
|
results.append(item)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"插件{tool_name}加载失败:{e}")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
sys.path.remove(str(plugins_dir))
|
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PluginRegistry(QObject):
|
|
|
|
|
|
"""插件注册中心。
|
|
|
|
|
|
|
|
|
|
|
|
主线程负责创建插件实例(create_plugin 返回 QWidget,必须在 GUI 线程创建);
|
|
|
|
|
|
发现与安装的 I/O 由 PluginWorker 在独立 QThread 中执行,结果通过信号回传
|
|
|
|
|
|
主线程。所有跨线程通信经 Qt 信号槽(Queued)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
plugins_loader_signal = Signal(dict)
|
|
|
|
|
|
update_plugins_card_signal = Signal(dict)
|
2026-07-20 13:15:16 +08:00
|
|
|
|
uninstall_completed_signal = Signal(str)
|
2026-07-20 10:31:10 +08:00
|
|
|
|
|
|
|
|
|
|
_discover_local_requested = Signal(str)
|
|
|
|
|
|
_discover_remote_requested = Signal(str)
|
|
|
|
|
|
_install_requested = Signal(str, str)
|
2026-07-20 13:15:16 +08:00
|
|
|
|
_uninstall_requested = Signal(str, str)
|
2026-07-20 10:31:10 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, parent: Optional[QObject] = None) -> None:
|
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
|
self.plugins: dict = {}
|
|
|
|
|
|
|
2026-07-20 13:15:16 +08:00
|
|
|
|
# 启动时先清理上次遗留的待删清单(此时相关 .pyd 尚未 import,
|
|
|
|
|
|
# 且 worker 线程未启动,无并发文件访问)
|
|
|
|
|
|
self._process_pending_uninstalls()
|
|
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
self._worker = PluginWorker()
|
|
|
|
|
|
self._thread = QThread()
|
|
|
|
|
|
self._worker.moveToThread(self._thread)
|
|
|
|
|
|
|
|
|
|
|
|
self._discover_local_requested.connect(self._worker.do_discover_local)
|
|
|
|
|
|
self._discover_remote_requested.connect(self._worker.do_discover_remote)
|
|
|
|
|
|
self._install_requested.connect(self._worker.do_install)
|
2026-07-20 13:15:16 +08:00
|
|
|
|
self._uninstall_requested.connect(self._worker.do_uninstall)
|
2026-07-20 10:31:10 +08:00
|
|
|
|
|
|
|
|
|
|
self._worker.local_discovered.connect(self._on_local_discovered)
|
|
|
|
|
|
self._worker.remote_discovered.connect(self._on_remote_discovered)
|
|
|
|
|
|
self._worker.install_finished.connect(self._on_install_finished)
|
2026-07-20 13:15:16 +08:00
|
|
|
|
self._worker.uninstall_finished.connect(self._on_uninstall_finished)
|
|
|
|
|
|
self._thread.finished.connect(self._worker.deleteLater)
|
2026-07-20 10:31:10 +08:00
|
|
|
|
|
|
|
|
|
|
self._thread.start()
|
|
|
|
|
|
# 延迟到事件循环启动后触发,保证所有接收方先 connect 后 emit
|
|
|
|
|
|
QTimer.singleShot(0, self._start_discovery)
|
|
|
|
|
|
|
2026-07-20 13:15:16 +08:00
|
|
|
|
def shutdown(self) -> None:
|
|
|
|
|
|
"""优雅停止工作线程:请求事件循环退出并等待真正结束。
|
|
|
|
|
|
|
|
|
|
|
|
必须在 QThread 析构前调用,否则触发
|
|
|
|
|
|
"QThread: Destroyed while thread is still running" 警告。
|
|
|
|
|
|
"""
|
|
|
|
|
|
self._thread.quit()
|
|
|
|
|
|
self._thread.wait(3000)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _process_pending_uninstalls() -> None:
|
|
|
|
|
|
"""启动时执行待删清单:删除 .pyd 文件并清空清单。
|
|
|
|
|
|
|
|
|
|
|
|
必须在任何 importlib.import_module 之前调用,此时 .pyd 未被加载,
|
|
|
|
|
|
Windows 文件锁不会触发 WinError 5。但自重启场景下,原进程可能尚未
|
|
|
|
|
|
完全释放文件句柄,因此删除失败时短暂重试。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not UNINSTALL_PENDING_FILE.exists():
|
|
|
|
|
|
return
|
|
|
|
|
|
lines = UNINSTALL_PENDING_FILE.read_text(encoding='utf-8').splitlines()
|
|
|
|
|
|
pending = [ln.strip() for ln in lines if ln.strip()]
|
|
|
|
|
|
failed: list = []
|
|
|
|
|
|
for tool_name in pending:
|
|
|
|
|
|
target = LOCAL_PLUGINS_PATH / f"{tool_name}.pyd"
|
|
|
|
|
|
if not target.exists():
|
|
|
|
|
|
continue
|
|
|
|
|
|
if _remove_with_retry(target):
|
|
|
|
|
|
logger.info(f"已删除插件文件: {target}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
failed.append(tool_name)
|
|
|
|
|
|
logger.error(f"删除插件文件失败(重试后仍被占用): {target}")
|
|
|
|
|
|
if failed:
|
|
|
|
|
|
UNINSTALL_PENDING_FILE.write_text("\n".join(failed) + "\n", encoding='utf-8')
|
|
|
|
|
|
else:
|
|
|
|
|
|
UNINSTALL_PENDING_FILE.unlink()
|
|
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
def _start_discovery(self) -> None:
|
|
|
|
|
|
self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH))
|
|
|
|
|
|
self._discover_remote_requested.emit(str(REMOTE_PLUGINS_PATH))
|
|
|
|
|
|
|
|
|
|
|
|
@Slot(list)
|
|
|
|
|
|
def _on_local_discovered(self, results: list) -> None:
|
|
|
|
|
|
for item in results:
|
|
|
|
|
|
name = item["name"]
|
2026-07-20 13:15:16 +08:00
|
|
|
|
try:
|
|
|
|
|
|
module = importlib.import_module(item["tool_name"])
|
|
|
|
|
|
obj = module.create_plugin()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"本地插件 {name} 实例化失败,跳过: {e}")
|
|
|
|
|
|
continue
|
2026-07-20 10:31:10 +08:00
|
|
|
|
if name not in self.plugins:
|
|
|
|
|
|
self.plugins[name] = {}
|
2026-07-20 13:15:16 +08:00
|
|
|
|
self.plugins[name]["obj"] = obj
|
2026-07-20 10:31:10 +08:00
|
|
|
|
self.plugins[name]["local version"] = item["version"]
|
|
|
|
|
|
self.plugins_loader_signal.emit(self.plugins)
|
|
|
|
|
|
|
|
|
|
|
|
@Slot(list)
|
|
|
|
|
|
def _on_remote_discovered(self, results: list) -> None:
|
|
|
|
|
|
for item in results:
|
|
|
|
|
|
name = item["name"]
|
|
|
|
|
|
if name not in self.plugins:
|
|
|
|
|
|
self.plugins[name] = {}
|
|
|
|
|
|
self.plugins[name]["local version"] = None
|
|
|
|
|
|
self.plugins[name]["local description"] = None
|
|
|
|
|
|
self.plugins[name]["tool_name"] = item["tool_name"]
|
|
|
|
|
|
self.plugins[name]["remote version"] = item["version"]
|
|
|
|
|
|
self.plugins[name]["remote description"] = item["description"]
|
|
|
|
|
|
self.plugins[name]["remote path"] = item["remote_path"]
|
|
|
|
|
|
self.update_plugins_card_signal.emit(self.plugins)
|
|
|
|
|
|
|
|
|
|
|
|
@Slot(bool, str)
|
|
|
|
|
|
def _on_install_finished(self, success: bool, message: str) -> None:
|
|
|
|
|
|
if success:
|
|
|
|
|
|
logger.info(message)
|
|
|
|
|
|
self._start_discovery()
|
|
|
|
|
|
# self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH))
|
|
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(message)
|
|
|
|
|
|
|
2026-07-20 13:15:16 +08:00
|
|
|
|
@Slot(str, bool, str)
|
|
|
|
|
|
def _on_uninstall_finished(self, name: str, success: bool, message: str) -> None:
|
|
|
|
|
|
if not success:
|
|
|
|
|
|
logger.error(message)
|
|
|
|
|
|
return
|
|
|
|
|
|
logger.info(message)
|
|
|
|
|
|
info = self.plugins.get(name)
|
|
|
|
|
|
if info is not None:
|
|
|
|
|
|
info.pop("obj", None)
|
|
|
|
|
|
info["local version"] = None
|
|
|
|
|
|
self.uninstall_completed_signal.emit(name)
|
|
|
|
|
|
|
2026-07-20 10:31:10 +08:00
|
|
|
|
def start_plugins_event(self, event: Event, name: str) -> None:
|
|
|
|
|
|
if event == Event.Install:
|
|
|
|
|
|
tool_name = self.plugins[name]['tool_name']
|
|
|
|
|
|
local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd"
|
|
|
|
|
|
remote_path = str(REMOTE_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd"
|
|
|
|
|
|
self._install_requested.emit(local_path, remote_path)
|
|
|
|
|
|
elif event == Event.Uninstall:
|
|
|
|
|
|
tool_name = self.plugins[name]['tool_name']
|
|
|
|
|
|
local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd"
|
2026-07-20 13:15:16 +08:00
|
|
|
|
self._uninstall_requested.emit(name, local_path)
|