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 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user