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:
@@ -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,13 +160,20 @@ 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)
|
||||
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(),
|
||||
@@ -134,11 +182,10 @@ class PluginWorker(QObject):
|
||||
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}")
|
||||
finally:
|
||||
sys.path.remove(str(plugins_dir))
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user