From ca6c538060b55a3e9b5f5bc18e4488253720fdaa Mon Sep 17 00:00:00 2001 From: "feifei.xu" <307327147@qq.com> Date: Mon, 20 Jul 2026 14:25:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E5=AE=9E=E7=8E=B0=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PluginRegistry 新增 _update_requested 信号与 do_update slot - 待处理清单扩展支持 tool_name|remote_path 更新行格式 - 启动时 _process_pending_uninstalls 删旧 .pyd 后从服务器复制新文件 - PluginsCard 新增 parse_version 版本比较与 on_update_event - 更新链路复用卸载的自重启机制,一次重启完成更新 --- core/plugin/plugin_registry.py | 133 +++++++++++++++++++++++++-------- core/plugin/plugins_card.py | 37 +++++++-- 2 files changed, 133 insertions(+), 37 deletions(-) diff --git a/core/plugin/plugin_registry.py b/core/plugin/plugin_registry.py index de0e5e3..4ef44f4 100644 --- a/core/plugin/plugin_registry.py +++ b/core/plugin/plugin_registry.py @@ -10,8 +10,10 @@ import os import sys import time +import shutil import logging import importlib +import importlib.util from pathlib import Path from enum import Enum, auto @@ -19,6 +21,7 @@ from typing import Optional from PySide6.QtCore import QObject, Signal, Slot, QThread, QTimer +WORKSPACE_PATH = Path("./workspace") LOCAL_PLUGINS_PATH = Path("./plugins") REMOTE_PLUGINS_PATH = Path('Y:/SE/xufeifei/plugins') UNINSTALL_PENDING_FILE = LOCAL_PLUGINS_PATH / ".uninstall_pending" @@ -39,6 +42,23 @@ def _remove_with_retry(target: Path, retries: int = 5, interval: float = 0.2) -> return False +def _load_module_from_path(module_name: str, file_path: Path): + """按文件路径加载模块,绕过 sys.modules 缓存。 + + 用 spec_from_file_location 直接从 .pyd 文件构建模块,避免 + importlib.import_module 命中同名旧缓存。注意:.pyd 的入口函数 + PyInit_ 与文件名绑定,module_name 必须等于文件 stem, + 不能用别名。 + """ + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise ImportError(f"无法为 {file_path} 创建模块规格") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + class Event(Enum): Update = auto() Install = auto() @@ -59,6 +79,7 @@ class PluginWorker(QObject): @Slot(str) def do_discover_local(self, plugins_dir: str) -> None: + print(plugins_dir) plugins_dir = Path(plugins_dir) if not self._ensure_dir(plugins_dir): return @@ -66,9 +87,11 @@ class PluginWorker(QObject): @Slot(str) def do_discover_remote(self, plugins_dir: str) -> None: + print(plugins_dir) if not os.path.exists(plugins_dir): - logger.error("服务器链接错误!") + logger.error(f"服务器链接错误!路径不存在: {plugins_dir}") return + logger.info(f"开始扫描远程插件: {plugins_dir}") self.remote_discovered.emit( self._scan_plugins(Path(plugins_dir), with_remote_meta=True) ) @@ -110,6 +133,24 @@ class PluginWorker(QObject): logger.error(f"{local_path}卸载失败{e.args}") self.uninstall_finished.emit(name, False, str(e)) + @Slot(str, str, str) + def do_update(self, name: str, local_path: str, remote_path: str) -> None: + """更新=删旧 .pyd + 从服务器复制新 .pyd。 + + 运行时 .pyd 已加载无法删除,记入待处理清单(含 remote_path), + 重启时由 _process_pending_uninstalls 删除旧文件并复制新文件。 + 运行时 UI 清理复用 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}|{remote_path}\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)) + @staticmethod def _ensure_dir(plugins_dir: Path) -> bool: if not os.path.exists(plugins_dir): @@ -119,26 +160,32 @@ class PluginWorker(QObject): @staticmethod def _scan_plugins(plugins_dir: Path, with_remote_meta: bool = False) -> list: + """扫描目录下的 .pyd 插件,按文件路径加载读取元数据。 + + .pyd 是 C 扩展,入口函数 PyInit_<文件名> 与文件名绑定,无法用别名 + 加载。因此本地与远程都用原名加载,但远程扫描前清掉 sys.modules + 缓存(避免读到本地旧版本),读完后再次清缓存(避免污染本地逻辑)。 + 本地扫描用原名加载并保留缓存,供 _on_local_discovered 二次 import。 + """ 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)) + for tool in plugins_dir.glob("*.pyd"): + tool_name = tool.stem + try: + if with_remote_meta: + sys.modules.pop(tool_name, None) + module = _load_module_from_path(tool_name, tool) + 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) + sys.modules.pop(tool_name, None) + results.append(item) + except Exception as e: + logger.error(f"插件{tool_name}加载失败:{e}") return results @@ -158,6 +205,7 @@ class PluginRegistry(QObject): _discover_remote_requested = Signal(str) _install_requested = Signal(str, str) _uninstall_requested = Signal(str, str) + _update_requested = Signal(str, str, str) def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent) @@ -175,6 +223,7 @@ class PluginRegistry(QObject): self._discover_remote_requested.connect(self._worker.do_discover_remote) self._install_requested.connect(self._worker.do_install) self._uninstall_requested.connect(self._worker.do_uninstall) + self._update_requested.connect(self._worker.do_update) self._worker.local_discovered.connect(self._on_local_discovered) self._worker.remote_discovered.connect(self._on_remote_discovered) @@ -197,26 +246,43 @@ class PluginRegistry(QObject): @staticmethod def _process_pending_uninstalls() -> None: - """启动时执行待删清单:删除 .pyd 文件并清空清单。 + """启动时执行待处理清单:删除 .pyd 文件,更新项另从服务器复制新文件。 + + 清单行格式: + - ``tool_name`` 纯卸载,仅删除本地 .pyd + - ``tool_name|remote_path`` 更新,删除后从 remote_path 复制新文件 必须在任何 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(): + for line in lines: + line = line.strip() + if not line: continue - if _remove_with_retry(target): - logger.info(f"已删除插件文件: {target}") - else: - failed.append(tool_name) + parts = line.split('|', 1) + tool_name = parts[0] + target = LOCAL_PLUGINS_PATH / f"{tool_name}.pyd" + # 纯卸载且文件已不存在视为已完成;更新项即使文件不存在也要继续复制 + if not target.exists() and len(parts) == 1: + continue + if not _remove_with_retry(target): + failed.append(line) logger.error(f"删除插件文件失败(重试后仍被占用): {target}") + continue + logger.info(f"已删除插件文件: {target}") + if len(parts) == 2: + remote_path = Path(parts[1]) + try: + shutil.copyfile(remote_path, target) + logger.info(f"已更新插件文件: {target}") + except Exception as e: + failed.append(line) + logger.error(f"复制更新文件失败: {e}") if failed: UNINSTALL_PENDING_FILE.write_text("\n".join(failed) + "\n", encoding='utf-8') else: @@ -232,7 +298,7 @@ class PluginRegistry(QObject): name = item["name"] try: module = importlib.import_module(item["tool_name"]) - obj = module.create_plugin() + obj = module.create_plugin(WORKSPACE_PATH) except Exception as e: logger.error(f"本地插件 {name} 实例化失败,跳过: {e}") continue @@ -288,3 +354,8 @@ class PluginRegistry(QObject): tool_name = self.plugins[name]['tool_name'] local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd" self._uninstall_requested.emit(name, local_path) + elif event == Event.Update: + 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._update_requested.emit(name, local_path, remote_path) diff --git a/core/plugin/plugins_card.py b/core/plugin/plugins_card.py index 598d3d7..458dd53 100644 --- a/core/plugin/plugins_card.py +++ b/core/plugin/plugins_card.py @@ -10,6 +10,29 @@ from .plugins_card_ui import Ui_FramePluginsCard logger = logging.getLogger(__name__) + +def parse_version(version: str) -> tuple: + """将版本字符串解析为可比较的元组,如 '1.10.0' -> (1, 10, 0)。 + + 非数字段保持原样(字符串与整数混排时按元组规则比较,足够覆盖 + 常见的 '1.0.0' / '1.10.3' 场景)。None/空值/解析失败返回 (0,), + 视为最低版本。 + """ + if not version: + return (0,) + try: + parts = [] + for seg in str(version).split('.'): + seg = seg.strip() + if seg.isdigit(): + parts.append(int(seg)) + else: + parts.append(seg) + return tuple(parts) if parts else (0,) + except Exception: + return (0,) + + class PluginsCard(QFrame, Ui_FramePluginsCard): """工具卡片类 @@ -27,9 +50,9 @@ class PluginsCard(QFrame, Ui_FramePluginsCard): self.initUI() def initUI(self): - self.textBrowser.setReadOnly(True) - self.refresh() + self.textBrowser.setReadOnly(True) + self.pushButtonUpdate.clicked.connect(self.on_update_event) self.pushButtonInstall.clicked.connect(self.on_install_event) self.pushButtonUninstall.clicked.connect(self.on_uninstall_event) @@ -40,9 +63,9 @@ class PluginsCard(QFrame, Ui_FramePluginsCard): self.pushButtonInstall.setEnabled(not installed) self.pushButtonUninstall.setEnabled(installed) if installed: - self.pushButtonUpdate.setEnabled( - self.info['remote version'] > self.info['local version'] - ) + remote = parse_version(self.info.get('remote version')) + local = parse_version(self.info.get('local version')) + self.pushButtonUpdate.setEnabled(remote > local) else: self.pushButtonUpdate.setEnabled(False) @@ -61,4 +84,6 @@ class PluginsCard(QFrame, Ui_FramePluginsCard): def on_uninstall_event(self): self.send_event_signal.emit(Event.Uninstall, self.name) - pass \ No newline at end of file + + def on_update_event(self): + self.send_event_signal.emit(Event.Update, self.name) \ No newline at end of file