feat(plugin): 实现插件更新功能

- PluginRegistry 新增 _update_requested 信号与 do_update slot
- 待处理清单扩展支持 tool_name|remote_path 更新行格式
- 启动时 _process_pending_uninstalls 删旧 .pyd 后从服务器复制新文件
- PluginsCard 新增 parse_version 版本比较与 on_update_event
- 更新链路复用卸载的自重启机制,一次重启完成更新
This commit is contained in:
2026-07-20 14:25:18 +08:00
parent 51e30d41c6
commit ca6c538060
2 changed files with 133 additions and 37 deletions
+88 -17
View File
@@ -10,8 +10,10 @@
import os import os
import sys import sys
import time import time
import shutil
import logging import logging
import importlib import importlib
import importlib.util
from pathlib import Path from pathlib import Path
from enum import Enum, auto from enum import Enum, auto
@@ -19,6 +21,7 @@ from typing import Optional
from PySide6.QtCore import QObject, Signal, Slot, QThread, QTimer from PySide6.QtCore import QObject, Signal, Slot, QThread, QTimer
WORKSPACE_PATH = Path("./workspace")
LOCAL_PLUGINS_PATH = Path("./plugins") LOCAL_PLUGINS_PATH = Path("./plugins")
REMOTE_PLUGINS_PATH = Path('Y:/SE/xufeifei/plugins') REMOTE_PLUGINS_PATH = Path('Y:/SE/xufeifei/plugins')
UNINSTALL_PENDING_FILE = LOCAL_PLUGINS_PATH / ".uninstall_pending" 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 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_<name> 与文件名绑定,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): class Event(Enum):
Update = auto() Update = auto()
Install = auto() Install = auto()
@@ -59,6 +79,7 @@ class PluginWorker(QObject):
@Slot(str) @Slot(str)
def do_discover_local(self, plugins_dir: str) -> None: def do_discover_local(self, plugins_dir: str) -> None:
print(plugins_dir)
plugins_dir = Path(plugins_dir) plugins_dir = Path(plugins_dir)
if not self._ensure_dir(plugins_dir): if not self._ensure_dir(plugins_dir):
return return
@@ -66,9 +87,11 @@ class PluginWorker(QObject):
@Slot(str) @Slot(str)
def do_discover_remote(self, plugins_dir: str) -> None: def do_discover_remote(self, plugins_dir: str) -> None:
print(plugins_dir)
if not os.path.exists(plugins_dir): if not os.path.exists(plugins_dir):
logger.error("服务器链接错误!") logger.error(f"服务器链接错误!路径不存在: {plugins_dir}")
return return
logger.info(f"开始扫描远程插件: {plugins_dir}")
self.remote_discovered.emit( self.remote_discovered.emit(
self._scan_plugins(Path(plugins_dir), with_remote_meta=True) self._scan_plugins(Path(plugins_dir), with_remote_meta=True)
) )
@@ -110,6 +133,24 @@ class PluginWorker(QObject):
logger.error(f"{local_path}卸载失败{e.args}") logger.error(f"{local_path}卸载失败{e.args}")
self.uninstall_finished.emit(name, False, str(e)) 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 @staticmethod
def _ensure_dir(plugins_dir: Path) -> bool: def _ensure_dir(plugins_dir: Path) -> bool:
if not os.path.exists(plugins_dir): if not os.path.exists(plugins_dir):
@@ -119,13 +160,20 @@ class PluginWorker(QObject):
@staticmethod @staticmethod
def _scan_plugins(plugins_dir: Path, with_remote_meta: bool = False) -> list: def _scan_plugins(plugins_dir: Path, with_remote_meta: bool = False) -> list:
"""扫描目录下的 .pyd 插件,按文件路径加载读取元数据。
.pyd 是 C 扩展,入口函数 PyInit_<文件名> 与文件名绑定,无法用别名
加载。因此本地与远程都用原名加载,但远程扫描前清掉 sys.modules
缓存(避免读到本地旧版本),读完后再次清缓存(避免污染本地逻辑)。
本地扫描用原名加载并保留缓存,供 _on_local_discovered 二次 import。
"""
results: list = [] results: list = []
sys.path.append(str(plugins_dir))
try:
for tool in plugins_dir.glob("*.pyd"): for tool in plugins_dir.glob("*.pyd"):
tool_name = tool.stem tool_name = tool.stem
try: try:
module = importlib.import_module(tool_name) if with_remote_meta:
sys.modules.pop(tool_name, None)
module = _load_module_from_path(tool_name, tool)
item = { item = {
"tool_name": tool_name, "tool_name": tool_name,
"name": module.read_plugin_name(), "name": module.read_plugin_name(),
@@ -134,11 +182,10 @@ class PluginWorker(QObject):
if with_remote_meta: if with_remote_meta:
item["description"] = module.read_plugin_description() item["description"] = module.read_plugin_description()
item["remote_path"] = str(plugins_dir) item["remote_path"] = str(plugins_dir)
sys.modules.pop(tool_name, None)
results.append(item) results.append(item)
except Exception as e: except Exception as e:
logger.error(f"插件{tool_name}加载失败:{e}") logger.error(f"插件{tool_name}加载失败:{e}")
finally:
sys.path.remove(str(plugins_dir))
return results return results
@@ -158,6 +205,7 @@ class PluginRegistry(QObject):
_discover_remote_requested = Signal(str) _discover_remote_requested = Signal(str)
_install_requested = Signal(str, str) _install_requested = Signal(str, str)
_uninstall_requested = Signal(str, str) _uninstall_requested = Signal(str, str)
_update_requested = Signal(str, str, str)
def __init__(self, parent: Optional[QObject] = None) -> None: def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -175,6 +223,7 @@ class PluginRegistry(QObject):
self._discover_remote_requested.connect(self._worker.do_discover_remote) self._discover_remote_requested.connect(self._worker.do_discover_remote)
self._install_requested.connect(self._worker.do_install) self._install_requested.connect(self._worker.do_install)
self._uninstall_requested.connect(self._worker.do_uninstall) 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.local_discovered.connect(self._on_local_discovered)
self._worker.remote_discovered.connect(self._on_remote_discovered) self._worker.remote_discovered.connect(self._on_remote_discovered)
@@ -197,26 +246,43 @@ class PluginRegistry(QObject):
@staticmethod @staticmethod
def _process_pending_uninstalls() -> None: def _process_pending_uninstalls() -> None:
"""启动时执行待清单:删除 .pyd 文件并清空清单 """启动时执行待处理清单:删除 .pyd 文件,更新项另从服务器复制新文件
清单行格式:
- ``tool_name`` 纯卸载,仅删除本地 .pyd
- ``tool_name|remote_path`` 更新,删除后从 remote_path 复制新文件
必须在任何 importlib.import_module 之前调用,此时 .pyd 未被加载, 必须在任何 importlib.import_module 之前调用,此时 .pyd 未被加载,
Windows 文件锁不会触发 WinError 5。但自重启场景下,原进程可能尚未 Windows 文件锁不会触发 WinError 5。但自重启场景下,原进程可能尚未
完全释放文件句柄,因此删除失败时短暂重试。 完全释放文件句柄,因此删除失败时短暂重试。失败项保留原始清单行待下次处理。
""" """
if not UNINSTALL_PENDING_FILE.exists(): if not UNINSTALL_PENDING_FILE.exists():
return return
lines = UNINSTALL_PENDING_FILE.read_text(encoding='utf-8').splitlines() lines = UNINSTALL_PENDING_FILE.read_text(encoding='utf-8').splitlines()
pending = [ln.strip() for ln in lines if ln.strip()]
failed: list = [] failed: list = []
for tool_name in pending: for line in lines:
target = LOCAL_PLUGINS_PATH / f"{tool_name}.pyd" line = line.strip()
if not target.exists(): if not line:
continue continue
if _remove_with_retry(target): parts = line.split('|', 1)
logger.info(f"已删除插件文件: {target}") tool_name = parts[0]
else: target = LOCAL_PLUGINS_PATH / f"{tool_name}.pyd"
failed.append(tool_name) # 纯卸载且文件已不存在视为已完成;更新项即使文件不存在也要继续复制
if not target.exists() and len(parts) == 1:
continue
if not _remove_with_retry(target):
failed.append(line)
logger.error(f"删除插件文件失败(重试后仍被占用): {target}") 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: if failed:
UNINSTALL_PENDING_FILE.write_text("\n".join(failed) + "\n", encoding='utf-8') UNINSTALL_PENDING_FILE.write_text("\n".join(failed) + "\n", encoding='utf-8')
else: else:
@@ -232,7 +298,7 @@ class PluginRegistry(QObject):
name = item["name"] name = item["name"]
try: try:
module = importlib.import_module(item["tool_name"]) module = importlib.import_module(item["tool_name"])
obj = module.create_plugin() obj = module.create_plugin(WORKSPACE_PATH)
except Exception as e: except Exception as e:
logger.error(f"本地插件 {name} 实例化失败,跳过: {e}") logger.error(f"本地插件 {name} 实例化失败,跳过: {e}")
continue continue
@@ -288,3 +354,8 @@ class PluginRegistry(QObject):
tool_name = self.plugins[name]['tool_name'] tool_name = self.plugins[name]['tool_name']
local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd" local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd"
self._uninstall_requested.emit(name, local_path) 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)
+31 -6
View File
@@ -10,6 +10,29 @@ from .plugins_card_ui import Ui_FramePluginsCard
logger = logging.getLogger(__name__) 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): class PluginsCard(QFrame, Ui_FramePluginsCard):
"""工具卡片类 """工具卡片类
@@ -27,9 +50,9 @@ class PluginsCard(QFrame, Ui_FramePluginsCard):
self.initUI() self.initUI()
def initUI(self): def initUI(self):
self.textBrowser.setReadOnly(True)
self.refresh() self.refresh()
self.textBrowser.setReadOnly(True)
self.pushButtonUpdate.clicked.connect(self.on_update_event)
self.pushButtonInstall.clicked.connect(self.on_install_event) self.pushButtonInstall.clicked.connect(self.on_install_event)
self.pushButtonUninstall.clicked.connect(self.on_uninstall_event) self.pushButtonUninstall.clicked.connect(self.on_uninstall_event)
@@ -40,9 +63,9 @@ class PluginsCard(QFrame, Ui_FramePluginsCard):
self.pushButtonInstall.setEnabled(not installed) self.pushButtonInstall.setEnabled(not installed)
self.pushButtonUninstall.setEnabled(installed) self.pushButtonUninstall.setEnabled(installed)
if installed: if installed:
self.pushButtonUpdate.setEnabled( remote = parse_version(self.info.get('remote version'))
self.info['remote version'] > self.info['local version'] local = parse_version(self.info.get('local version'))
) self.pushButtonUpdate.setEnabled(remote > local)
else: else:
self.pushButtonUpdate.setEnabled(False) self.pushButtonUpdate.setEnabled(False)
@@ -61,4 +84,6 @@ class PluginsCard(QFrame, Ui_FramePluginsCard):
def on_uninstall_event(self): def on_uninstall_event(self):
self.send_event_signal.emit(Event.Uninstall, self.name) self.send_event_signal.emit(Event.Uninstall, self.name)
pass
def on_update_event(self):
self.send_event_signal.emit(Event.Update, self.name)