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
+102 -31
View File
@@ -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_<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):
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)
+31 -6
View File
@@ -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
def on_update_event(self):
self.send_event_signal.emit(Event.Update, self.name)