fix: 修复插件卸载后重启失败问题,增加安装进度日志

This commit is contained in:
2026-07-21 16:26:08 +08:00
parent ca6c538060
commit 0efaf2946b
9 changed files with 2704 additions and 2380 deletions
+188 -100
View File
@@ -1,16 +1,22 @@
"""插件注册中心。
架构说明:
- PluginWorker 运行在独立 QThread 中,仅做纯 I/O(扫描 .pyd、importlib 读元数据
文件复制),不创建 QObject、不操作 QWidget。
- PluginWorker 运行在独立 QThread 中,仅做纯 I/O(扫描 zip/.pyd、下载解压
读 manifest 元数据),不创建 QObject、不操作 QWidget。
- PluginRegistry 留在主线程,负责 create_plugin()(返回 QWidget,必须在 GUI 线程
创建)与信号转发。
- 跨线程通信全部走 Qt 信号槽(自动 Queued),不再使用 threading.Thread。
分发模型:
- 远程:每插件一个 <tool>-<version>-<python_tag>.zipexport_runtime + pack_zip 产物)
- 本地:解压到 plugins/<tool_name>/ 子目录,卸载删整个子目录
"""
import os
import sys
import time
import json
import shutil
import zipfile
import logging
import importlib
import importlib.util
@@ -29,19 +35,6 @@ UNINSTALL_PENDING_FILE = LOCAL_PLUGINS_PATH / ".uninstall_pending"
logger = logging.getLogger(__name__)
def _remove_with_retry(target: Path, retries: int = 5, interval: float = 0.2) -> bool:
"""删除文件,失败时短暂重试,应对自重启场景下原进程未完全释放句柄的情况。"""
for _ in range(retries):
try:
target.unlink()
return True
except PermissionError:
time.sleep(interval)
except FileNotFoundError:
return True
return False
def _load_module_from_path(module_name: str, file_path: Path):
"""按文件路径加载模块,绕过 sys.modules 缓存。
@@ -59,6 +52,15 @@ def _load_module_from_path(module_name: str, file_path: Path):
return module
def _pyd_module_name(file_path: Path) -> str:
"""从 .pyd 文件名提取 Python 模块名(PyInit_ 后缀)。
Nuitka 产物形如 mil.cp311-win_amd64.pydC 扩展入口函数为
PyInit_mil,因此模块名必须取第一个点之前的部分,不能用完整 stem。
"""
return file_path.name.split('.', 1)[0]
class Event(Enum):
Update = auto()
Install = auto()
@@ -79,76 +81,85 @@ class PluginWorker(QObject):
@Slot(str)
def do_discover_local(self, plugins_dir: str) -> None:
print(plugins_dir)
"""扫描 plugins/<插件名>/ 子目录下的主插件 .pyd。"""
plugins_dir = Path(plugins_dir)
if not self._ensure_dir(plugins_dir):
return
self.local_discovered.emit(self._scan_plugins(plugins_dir))
self.local_discovered.emit(self._scan_local_plugins(plugins_dir))
@Slot(str)
def do_discover_remote(self, plugins_dir: str) -> None:
print(plugins_dir)
"""扫描远程 *.zip,读 zip 内 manifest.json 取插件元数据。"""
if not os.path.exists(plugins_dir):
logger.error(f"服务器链接错误!路径不存在: {plugins_dir}")
return
logger.info(f"开始扫描远程插件: {plugins_dir}")
logger.info(f"开始扫描远程插件 zip: {plugins_dir}")
self.remote_discovered.emit(
self._scan_plugins(Path(plugins_dir), with_remote_meta=True)
self._scan_remote_zips(Path(plugins_dir))
)
@Slot(str, str)
def do_install(self, local_path: str, remote_path: str) -> None:
def do_install(self, local_dir: str, remote_zip: str) -> None:
"""下载 zip 流式写本地临时文件,再解压到 plugins/<插件名>/。
流式下载保留 install_progress 信号,便于 UI 显示进度条。
"""
try:
total_size = os.path.getsize(remote_path)
copied_size = 0
with open(remote_path, 'rb') as fsrc, open(local_path, 'wb') as fdst:
total_size = os.path.getsize(remote_zip)
target = Path(local_dir)
target.mkdir(parents=True, exist_ok=True)
tmp_zip = target / ".download.tmp"
copied = 0
with open(remote_zip, 'rb') as fsrc, open(tmp_zip, 'wb') as fdst:
while True:
buf = fsrc.read(1024 * 1024)
if not buf:
break
fdst.write(buf)
copied_size += len(buf)
self.install_progress.emit(copied_size, total_size)
time.sleep(0.1)
self.install_finished.emit(True, f"{local_path}安装成功!!!")
copied += len(buf)
self.install_progress.emit(copied, total_size)
with zipfile.ZipFile(tmp_zip, 'r') as zf:
zf.extractall(target)
tmp_zip.unlink()
self.install_finished.emit(True, f"安装成功: {local_dir}")
except Exception as e:
logger.error(f"{remote_path}安装失败{e.args}")
logger.error(f"{remote_zip}安装失败{e.args}")
self.install_finished.emit(False, str(e))
@Slot(str, str)
def do_uninstall(self, name: str, local_path: str) -> None:
"""运行时无法删除已加载 .pyd(Windows 文件锁),仅记入待删清单
def do_uninstall(self, name: str, local_dir: str) -> None:
"""运行时无法删除已加载 .pyd,记入待删清单(目录路径),重启时删整个目录
真正的文件删除由 PluginRegistry._process_pending_uninstalls 在下次启动、
真正的目录删除由 PluginRegistry._process_pending_uninstalls 在下次启动、
import 之前完成。运行时清理(字典/UI/widget)由主线程在 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}\n")
f.write(f"{local_dir}\n")
self.uninstall_finished.emit(name, True, f"{name}已加入卸载清单,重启后生效!!!")
except Exception as e:
logger.error(f"{local_path}卸载失败{e.args}")
logger.error(f"{local_dir}卸载失败{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
def do_update(self, name: str, local_dir: str, remote_zip: str) -> None:
"""更新=删旧目录+从远程 zip 解压新版本。运行时记清单,重启时执行
运行时 .pyd 已加载无法删除,记入待处理清单(含 remote_path),
重启时由 _process_pending_uninstalls 删除旧文件并复制新文件
运行时 .pyd 已加载无法删除,记入待处理清单(含 remote_zip),
重启时由 _process_pending_uninstalls 删除旧目录并解压新 zip
运行时 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")
f.write(f"{local_dir}|{remote_zip}\n")
self.uninstall_finished.emit(name, True, f"{name}更新已加入清单,重启后生效!!!")
except Exception as e:
logger.error(f"{local_path}更新失败{e.args}")
logger.error(f"{local_dir}更新失败{e.args}")
self.uninstall_finished.emit(name, False, str(e))
@staticmethod
@@ -159,33 +170,56 @@ class PluginWorker(QObject):
return True
@staticmethod
def _scan_plugins(plugins_dir: Path, with_remote_meta: bool = False) -> list:
"""扫描目录下的 .pyd 插件,按文件路径加载读取元数据。
def _scan_local_plugins(plugins_dir: Path) -> list:
"""扫描 plugins/<插件名>/ 子目录,读主插件 .pyd 元数据。
.pyd 是 C 扩展,入口函数 PyInit_<文件名> 与文件名绑定,无法用别名
加载。因此本地与远程都用原名加载,但远程扫描前清掉 sys.modules
缓存(避免读到本地旧版本),读完后再次清缓存(避免污染本地逻辑)。
本地扫描用原名加载并保留缓存,供 _on_local_discovered 二次 import。
跳过伴生包(openpyxl/et_xmlfile)与 C 扩展(下划线开头)。
每个插件子目录下只应有一个主插件 .pyd。
"""
results: list = []
for tool in plugins_dir.glob("*.pyd"):
tool_name = tool.stem
for sub in plugins_dir.iterdir():
if not sub.is_dir():
continue
for tool in sub.glob("*.pyd"):
# .pyd 文件名形如 mil.cp311-win_amd64.pyd
# 模块名取第一个点之前的部分(PyInit_ 后缀)
tool_name = _pyd_module_name(tool)
# 伴生包与 C 扩展不是主插件,跳过
if tool_name in ("openpyxl", "et_xmlfile") or tool_name.startswith("_"):
continue
try:
module = _load_module_from_path(tool_name, tool)
results.append({
"tool_name": tool_name,
"name": module.read_plugin_name(),
"version": module.read_plugin_version(),
"local_dir": str(sub),
})
except Exception as e:
logger.error(f"插件{tool_name}加载失败:{e}")
return results
@staticmethod
def _scan_remote_zips(plugins_dir: Path) -> list:
"""扫描远程 *.zip,读 zip 内 manifest.json 取插件元数据。
zip 文件名约定:<tool_name>-<version>-<python_tag>.zip
manifest.json 由 export_runtime.py 生成,含 plugin_name/version/description。
"""
results: list = []
for zip_file in plugins_dir.glob("*.zip"):
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)
with zipfile.ZipFile(zip_file, "r") as zf:
manifest = json.loads(zf.read("manifest.json"))
results.append({
"tool_name": manifest["tool_name"],
"name": manifest["plugin_name"],
"version": manifest["plugin_version"],
"description": manifest["plugin_description"],
"remote_path": str(zip_file),
})
except Exception as e:
logger.error(f"插件{tool_name}加载失败:{e}")
logger.error(f"远程 zip {zip_file.name} 读取失败:{e}")
return results
@@ -200,6 +234,8 @@ class PluginRegistry(QObject):
plugins_loader_signal = Signal(dict)
update_plugins_card_signal = Signal(dict)
uninstall_completed_signal = Signal(str)
install_progress = Signal(str, int, int) # name, copied_bytes, total_bytes
install_finished = Signal(bool, str)
_discover_local_requested = Signal(str)
_discover_remote_requested = Signal(str)
@@ -207,14 +243,17 @@ class PluginRegistry(QObject):
_uninstall_requested = Signal(str, str)
_update_requested = Signal(str, str, str)
_PENDING_CLEANUP_DELAY = 1500
def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent)
self.plugins: dict = {}
self._installing_name: str = ""
self._last_logged_pct: int = 0 # 节流:记录上次上报的百分比
# 启动时先清理上次遗留的待删清单(此时相关 .pyd 尚未 import
# 且 worker 线程未启动,无并发文件访问)
self._process_pending_uninstalls()
# Worker 在线程启动前创建(信号连接必须先于线程启动)
# 但实际启动延迟至清理完成后,避免新进程在 shutil.rmtree 之前
# 就把插件 .pyd 加载进内存导致自锁。
self._worker = PluginWorker()
self._thread = QThread()
self._worker.moveToThread(self._thread)
@@ -229,10 +268,22 @@ class PluginRegistry(QObject):
self._worker.remote_discovered.connect(self._on_remote_discovered)
self._worker.install_finished.connect(self._on_install_finished)
self._worker.uninstall_finished.connect(self._on_uninstall_finished)
self._worker.install_progress.connect(self._on_install_progress)
self._thread.finished.connect(self._worker.deleteLater)
# 延迟启动:先等旧进程彻底退出并释放 .pyd 句柄,
# 再执行待删清单清理,最后才扫描并加载插件。
# 顺序必须是 清理 → 启动线程 → 发现插件,否则新进程自锁。
QTimer.singleShot(self._PENDING_CLEANUP_DELAY, self._post_init)
def _post_init(self) -> None:
"""延迟初始化:清理 → 启动线程 → 发现插件。
必须在旧进程退出后执行,且清理必须在任何插件 import 之前完成,
否则新进程自己持有 .pyd 文件锁导致 shutil.rmtree 失败。
"""
self._process_pending_uninstalls()
self._thread.start()
# 延迟到事件循环启动后触发,保证所有接收方先 connect 后 emit
QTimer.singleShot(0, self._start_discovery)
def shutdown(self) -> None:
@@ -246,15 +297,15 @@ class PluginRegistry(QObject):
@staticmethod
def _process_pending_uninstalls() -> None:
"""启动时执行待处理清单:删除 .pyd 文件,更新项另从服务器复制新文件
"""启动时执行待处理清单:删除插件目录,更新项另从远程 zip 解压
清单行格式:
- ``tool_name`` 纯卸载,仅删除本地 .pyd
- ``tool_name|remote_path`` 更新,删除后从 remote_path 复制新文件
- ``<local_dir>`` 纯卸载,仅删除本地目录
- ``<local_dir>|<remote_zip>`` 更新,删除后从 remote_zip 解压新版本
必须在任何 importlib.import_module 之前调用,此时 .pyd 未被加载,
Windows 文件锁不会触发 WinError 5。但自重启场景下,原进程可能尚未
完全释放文件句柄,因此删除失败时短暂重试。失败项保留原始清单行待下次处理。
完全释放文件句柄,因此删除失败时保留清单行待下次处理。
"""
if not UNINSTALL_PENDING_FILE.exists():
return
@@ -265,24 +316,38 @@ class PluginRegistry(QObject):
if not line:
continue
parts = line.split('|', 1)
tool_name = parts[0]
target = LOCAL_PLUGINS_PATH / f"{tool_name}.pyd"
# 纯卸载且文件已不存在视为已完成;更新项即使文件不存在也要继续复制
target = Path(parts[0])
# 纯卸载且目录已不存在视为已完成;更新项即使目录不存在也要继续解压
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])
# 删除整个插件目录(带重试:Windows 文件锁可能在短时间后释放)
deleted = False
last_error = None
for attempt in range(3):
try:
shutil.copyfile(remote_path, target)
logger.info(f"更新插件文件: {target}")
shutil.rmtree(target)
logger.info(f"删除插件目录: {target}")
deleted = True
break
except Exception as e:
last_error = e
if attempt < 2:
time.sleep(0.5)
if not deleted:
failed.append(line)
logger.error(f"删除插件目录失败(可能仍被占用): {target} {last_error}")
continue
# 更新项:从远程 zip 解压新版本
if len(parts) == 2:
remote_zip = Path(parts[1])
try:
target.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(remote_zip, 'r') as zf:
zf.extractall(target)
logger.info(f"已更新插件: {target}")
except Exception as e:
failed.append(line)
logger.error(f"复制更新文件失败: {e}")
logger.error(f"解压更新失败: {e}")
if failed:
UNINSTALL_PENDING_FILE.write_text("\n".join(failed) + "\n", encoding='utf-8')
else:
@@ -306,7 +371,10 @@ class PluginRegistry(QObject):
self.plugins[name] = {}
self.plugins[name]["obj"] = obj
self.plugins[name]["local version"] = item["version"]
self.plugins[name]["local_dir"] = item["local_dir"]
self.plugins_loader_signal.emit(self.plugins)
# 本地扫描完成后也刷新卡片(安装后 local version 更新,卡片需重绘状态)
self.update_plugins_card_signal.emit(self.plugins)
@Slot(list)
def _on_remote_discovered(self, results: list) -> None:
@@ -326,11 +394,26 @@ class PluginRegistry(QObject):
def _on_install_finished(self, success: bool, message: str) -> None:
if success:
logger.info(message)
self._start_discovery()
# self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH))
for sub in LOCAL_PLUGINS_PATH.iterdir():
if sub.is_dir() and str(sub) not in sys.path:
sys.path.insert(0, str(sub))
self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH))
else:
logger.error(message)
self._installing_name = ""
self.install_finished.emit(success, message)
@Slot(int, int)
def _on_install_progress(self, copied: int, total: int) -> None:
"""将安装进度写入日志(显示在 FrameLog),按百分比节流避免刷屏。"""
if not self._installing_name or total == 0:
return
pct = int(copied / total * 100)
if pct - self._last_logged_pct >= 10 or pct == 100:
mb_copied = copied / 1024 / 1024
mb_total = total / 1024 / 1024
logger.info(f"[{self._installing_name}] 安装进度: {pct}% ({mb_copied:.1f}/{mb_total:.1f} MB)")
self._last_logged_pct = pct
@Slot(str, bool, str)
def _on_uninstall_finished(self, name: str, success: bool, message: str) -> None:
@@ -345,17 +428,22 @@ class PluginRegistry(QObject):
self.uninstall_completed_signal.emit(name)
def start_plugins_event(self, event: Event, name: str) -> None:
"""本地子目录路径作为 install/uninstall/update 的目标。
本地布局:plugins/<tool_name>/zip 解压产物)
远程来源:plugins[name]['remote path'] 指向 .zip 文件
"""
tool_name = self.plugins[name]['tool_name']
local_dir = str(LOCAL_PLUGINS_PATH / tool_name)
if event == Event.Install:
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._install_requested.emit(local_path, remote_path)
self._installing_name = name
self._last_logged_pct = 0
logger.info(f"[{name}] 开始安装...")
remote_path = self.plugins[name]['remote path']
self._install_requested.emit(local_dir, remote_path)
elif event == Event.Uninstall:
tool_name = self.plugins[name]['tool_name']
local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd"
self._uninstall_requested.emit(name, local_path)
self._uninstall_requested.emit(name, local_dir)
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)
self._installing_name = name
remote_path = self.plugins[name]['remote path']
self._update_requested.emit(name, local_dir, remote_path)