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
+3
View File
@@ -31,4 +31,7 @@ htmlcov/
config.json config.json
*.qm *.qm
*.ts *.ts
*.xml
*.trae/ *.trae/
/plugins
/logs
+6 -1
View File
@@ -18,7 +18,7 @@ BUILD_PATH = OUTPUT_PATH / f'{platform.system().lower()}-{platform.machine().low
def build_main(): def build_main():
nuitka_cmd = [ nuitka_cmd = [
'python', sys.executable,
'-m', '-m',
'nuitka', 'nuitka',
'--standalone', '--standalone',
@@ -26,6 +26,11 @@ def build_main():
'--show-progress', '--show-progress',
'--plugin-enable=pyside6', '--plugin-enable=pyside6',
'--include-module=qt_material', '--include-module=qt_material',
# 把插件 SDK 的 runtime_hook 编进 exe,启动时把 plugins/*/ 加进 sys.path。
# Nuitka 无 --runtime-hook 选项,用 --include-module + main.py 顶部 import 替代。
# 宿主不需要预知插件依赖哪些 stdlib——纯 Python 依赖编进 mil.pyd
# C 扩展由插件自带,runtime_hook 把 plugins/*/ 加进 sys.path 后能加载。
'--include-module=runtime_hook',
f"--include-data-dir={RESOURCES_PATH}=resources", f"--include-data-dir={RESOURCES_PATH}=resources",
] ]
+183 -95
View File
@@ -1,16 +1,22 @@
"""插件注册中心。 """插件注册中心。
架构说明: 架构说明:
- PluginWorker 运行在独立 QThread 中,仅做纯 I/O(扫描 .pyd、importlib 读元数据 - PluginWorker 运行在独立 QThread 中,仅做纯 I/O(扫描 zip/.pyd、下载解压
文件复制),不创建 QObject、不操作 QWidget。 读 manifest 元数据),不创建 QObject、不操作 QWidget。
- PluginRegistry 留在主线程,负责 create_plugin()(返回 QWidget,必须在 GUI 线程 - PluginRegistry 留在主线程,负责 create_plugin()(返回 QWidget,必须在 GUI 线程
创建)与信号转发。 创建)与信号转发。
- 跨线程通信全部走 Qt 信号槽(自动 Queued),不再使用 threading.Thread。 - 跨线程通信全部走 Qt 信号槽(自动 Queued),不再使用 threading.Thread。
分发模型:
- 远程:每插件一个 <tool>-<version>-<python_tag>.zipexport_runtime + pack_zip 产物)
- 本地:解压到 plugins/<tool_name>/ 子目录,卸载删整个子目录
""" """
import os import os
import sys import sys
import time import time
import json
import shutil import shutil
import zipfile
import logging import logging
import importlib import importlib
import importlib.util import importlib.util
@@ -29,19 +35,6 @@ UNINSTALL_PENDING_FILE = LOCAL_PLUGINS_PATH / ".uninstall_pending"
logger = logging.getLogger(__name__) 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): def _load_module_from_path(module_name: str, file_path: Path):
"""按文件路径加载模块,绕过 sys.modules 缓存。 """按文件路径加载模块,绕过 sys.modules 缓存。
@@ -59,6 +52,15 @@ def _load_module_from_path(module_name: str, file_path: Path):
return module 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): class Event(Enum):
Update = auto() Update = auto()
Install = auto() Install = auto()
@@ -79,76 +81,85 @@ 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/<插件名>/ 子目录下的主插件 .pyd。"""
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
self.local_discovered.emit(self._scan_plugins(plugins_dir)) self.local_discovered.emit(self._scan_local_plugins(plugins_dir))
@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) """扫描远程 *.zip,读 zip 内 manifest.json 取插件元数据。"""
if not os.path.exists(plugins_dir): if not os.path.exists(plugins_dir):
logger.error(f"服务器链接错误!路径不存在: {plugins_dir}") logger.error(f"服务器链接错误!路径不存在: {plugins_dir}")
return return
logger.info(f"开始扫描远程插件: {plugins_dir}") logger.info(f"开始扫描远程插件 zip: {plugins_dir}")
self.remote_discovered.emit( self.remote_discovered.emit(
self._scan_plugins(Path(plugins_dir), with_remote_meta=True) self._scan_remote_zips(Path(plugins_dir))
) )
@Slot(str, str) @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: try:
total_size = os.path.getsize(remote_path) total_size = os.path.getsize(remote_zip)
copied_size = 0 target = Path(local_dir)
with open(remote_path, 'rb') as fsrc, open(local_path, 'wb') as fdst: 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: while True:
buf = fsrc.read(1024 * 1024) buf = fsrc.read(1024 * 1024)
if not buf: if not buf:
break break
fdst.write(buf) fdst.write(buf)
copied_size += len(buf) copied += len(buf)
self.install_progress.emit(copied_size, total_size) self.install_progress.emit(copied, total_size)
time.sleep(0.1)
self.install_finished.emit(True, f"{local_path}安装成功!!!") 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: 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)) self.install_finished.emit(False, str(e))
@Slot(str, str) @Slot(str, str)
def do_uninstall(self, name: str, local_path: str) -> None: def do_uninstall(self, name: str, local_dir: str) -> None:
"""运行时无法删除已加载 .pyd(Windows 文件锁),仅记入待删清单 """运行时无法删除已加载 .pyd,记入待删清单(目录路径),重启时删整个目录
真正的文件删除由 PluginRegistry._process_pending_uninstalls 在下次启动、 真正的目录删除由 PluginRegistry._process_pending_uninstalls 在下次启动、
import 之前完成。运行时清理(字典/UI/widget)由主线程在 uninstall_finished import 之前完成。运行时清理(字典/UI/widget)由主线程在 uninstall_finished
回调中处理。 回调中处理。
""" """
try: try:
tool_name = Path(local_path).stem
UNINSTALL_PENDING_FILE.parent.mkdir(parents=True, exist_ok=True) UNINSTALL_PENDING_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(UNINSTALL_PENDING_FILE, 'a', encoding='utf-8') as f: 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}已加入卸载清单,重启后生效!!!") self.uninstall_finished.emit(name, True, f"{name}已加入卸载清单,重启后生效!!!")
except Exception as e: 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)) self.uninstall_finished.emit(name, False, str(e))
@Slot(str, str, str) @Slot(str, str, str)
def do_update(self, name: str, local_path: str, remote_path: str) -> None: def do_update(self, name: str, local_dir: str, remote_zip: str) -> None:
"""更新=删旧 .pyd + 从服务器复制新 .pyd """更新=删旧目录+从远程 zip 解压新版本。运行时记清单,重启时执行
运行时 .pyd 已加载无法删除,记入待处理清单(含 remote_path), 运行时 .pyd 已加载无法删除,记入待处理清单(含 remote_zip),
重启时由 _process_pending_uninstalls 删除旧文件并复制新文件 重启时由 _process_pending_uninstalls 删除旧目录并解压新 zip
运行时 UI 清理复用 uninstall_finished 信号链路,触发与卸载一致的自重启。 运行时 UI 清理复用 uninstall_finished 信号链路,触发与卸载一致的自重启。
""" """
try: try:
tool_name = Path(local_path).stem
UNINSTALL_PENDING_FILE.parent.mkdir(parents=True, exist_ok=True) UNINSTALL_PENDING_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(UNINSTALL_PENDING_FILE, 'a', encoding='utf-8') as f: 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}更新已加入清单,重启后生效!!!") self.uninstall_finished.emit(name, True, f"{name}更新已加入清单,重启后生效!!!")
except Exception as e: 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)) self.uninstall_finished.emit(name, False, str(e))
@staticmethod @staticmethod
@@ -159,35 +170,58 @@ class PluginWorker(QObject):
return True return True
@staticmethod @staticmethod
def _scan_plugins(plugins_dir: Path, with_remote_meta: bool = False) -> list: def _scan_local_plugins(plugins_dir: Path) -> list:
"""扫描目录下的 .pyd 插件,按文件路径加载读取元数据。 """扫描 plugins/<插件名>/ 子目录,读主插件 .pyd 元数据。
.pyd 是 C 扩展,入口函数 PyInit_<文件名> 与文件名绑定,无法用别名 跳过伴生包(openpyxl/et_xmlfile)与 C 扩展(下划线开头)。
加载。因此本地与远程都用原名加载,但远程扫描前清掉 sys.modules 每个插件子目录下只应有一个主插件 .pyd。
缓存(避免读到本地旧版本),读完后再次清缓存(避免污染本地逻辑)。
本地扫描用原名加载并保留缓存,供 _on_local_discovered 二次 import。
""" """
results: list = [] results: list = []
for tool in plugins_dir.glob("*.pyd"): for sub in plugins_dir.iterdir():
tool_name = tool.stem 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: try:
if with_remote_meta:
sys.modules.pop(tool_name, None)
module = _load_module_from_path(tool_name, tool) module = _load_module_from_path(tool_name, tool)
item = { results.append({
"tool_name": tool_name, "tool_name": tool_name,
"name": module.read_plugin_name(), "name": module.read_plugin_name(),
"version": module.read_plugin_version(), "version": module.read_plugin_version(),
} "local_dir": str(sub),
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: except Exception as e:
logger.error(f"插件{tool_name}加载失败:{e}") logger.error(f"插件{tool_name}加载失败:{e}")
return results 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:
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"远程 zip {zip_file.name} 读取失败:{e}")
return results
class PluginRegistry(QObject): class PluginRegistry(QObject):
"""插件注册中心。 """插件注册中心。
@@ -200,6 +234,8 @@ class PluginRegistry(QObject):
plugins_loader_signal = Signal(dict) plugins_loader_signal = Signal(dict)
update_plugins_card_signal = Signal(dict) update_plugins_card_signal = Signal(dict)
uninstall_completed_signal = Signal(str) 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_local_requested = Signal(str)
_discover_remote_requested = Signal(str) _discover_remote_requested = Signal(str)
@@ -207,14 +243,17 @@ class PluginRegistry(QObject):
_uninstall_requested = Signal(str, str) _uninstall_requested = Signal(str, str)
_update_requested = Signal(str, str, str) _update_requested = Signal(str, str, str)
_PENDING_CLEANUP_DELAY = 1500
def __init__(self, parent: Optional[QObject] = None) -> None: def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent) super().__init__(parent)
self.plugins: dict = {} self.plugins: dict = {}
self._installing_name: str = ""
self._last_logged_pct: int = 0 # 节流:记录上次上报的百分比
# 启动时先清理上次遗留的待删清单(此时相关 .pyd 尚未 import # Worker 在线程启动前创建(信号连接必须先于线程启动)
# 且 worker 线程未启动,无并发文件访问) # 但实际启动延迟至清理完成后,避免新进程在 shutil.rmtree 之前
self._process_pending_uninstalls() # 就把插件 .pyd 加载进内存导致自锁。
self._worker = PluginWorker() self._worker = PluginWorker()
self._thread = QThread() self._thread = QThread()
self._worker.moveToThread(self._thread) self._worker.moveToThread(self._thread)
@@ -229,10 +268,22 @@ class PluginRegistry(QObject):
self._worker.remote_discovered.connect(self._on_remote_discovered) self._worker.remote_discovered.connect(self._on_remote_discovered)
self._worker.install_finished.connect(self._on_install_finished) self._worker.install_finished.connect(self._on_install_finished)
self._worker.uninstall_finished.connect(self._on_uninstall_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) 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() self._thread.start()
# 延迟到事件循环启动后触发,保证所有接收方先 connect 后 emit
QTimer.singleShot(0, self._start_discovery) QTimer.singleShot(0, self._start_discovery)
def shutdown(self) -> None: def shutdown(self) -> None:
@@ -246,15 +297,15 @@ class PluginRegistry(QObject):
@staticmethod @staticmethod
def _process_pending_uninstalls() -> None: def _process_pending_uninstalls() -> None:
"""启动时执行待处理清单:删除 .pyd 文件,更新项另从服务器复制新文件 """启动时执行待处理清单:删除插件目录,更新项另从远程 zip 解压
清单行格式: 清单行格式:
- ``tool_name`` 纯卸载,仅删除本地 .pyd - ``<local_dir>`` 纯卸载,仅删除本地目录
- ``tool_name|remote_path`` 更新,删除后从 remote_path 复制新文件 - ``<local_dir>|<remote_zip>`` 更新,删除后从 remote_zip 解压新版本
必须在任何 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
@@ -265,24 +316,38 @@ class PluginRegistry(QObject):
if not line: if not line:
continue continue
parts = line.split('|', 1) parts = line.split('|', 1)
tool_name = parts[0] target = Path(parts[0])
target = LOCAL_PLUGINS_PATH / f"{tool_name}.pyd" # 纯卸载且目录已不存在视为已完成;更新项即使目录不存在也要继续解压
# 纯卸载且文件已不存在视为已完成;更新项即使文件不存在也要继续复制
if not target.exists() and len(parts) == 1: if not target.exists() and len(parts) == 1:
continue continue
if not _remove_with_retry(target): # 删除整个插件目录(带重试:Windows 文件锁可能在短时间后释放)
failed.append(line) deleted = False
logger.error(f"删除插件文件失败(重试后仍被占用): {target}") last_error = None
continue for attempt in range(3):
logger.info(f"已删除插件文件: {target}")
if len(parts) == 2:
remote_path = Path(parts[1])
try: try:
shutil.copyfile(remote_path, target) shutil.rmtree(target)
logger.info(f"更新插件文件: {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: except Exception as e:
failed.append(line) failed.append(line)
logger.error(f"复制更新文件失败: {e}") 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:
@@ -306,7 +371,10 @@ class PluginRegistry(QObject):
self.plugins[name] = {} self.plugins[name] = {}
self.plugins[name]["obj"] = obj self.plugins[name]["obj"] = obj
self.plugins[name]["local version"] = item["version"] self.plugins[name]["local version"] = item["version"]
self.plugins[name]["local_dir"] = item["local_dir"]
self.plugins_loader_signal.emit(self.plugins) self.plugins_loader_signal.emit(self.plugins)
# 本地扫描完成后也刷新卡片(安装后 local version 更新,卡片需重绘状态)
self.update_plugins_card_signal.emit(self.plugins)
@Slot(list) @Slot(list)
def _on_remote_discovered(self, results: list) -> None: 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: def _on_install_finished(self, success: bool, message: str) -> None:
if success: if success:
logger.info(message) logger.info(message)
self._start_discovery() for sub in LOCAL_PLUGINS_PATH.iterdir():
# self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH)) 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: else:
logger.error(message) 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) @Slot(str, bool, str)
def _on_uninstall_finished(self, name: str, success: bool, message: str) -> None: 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) self.uninstall_completed_signal.emit(name)
def start_plugins_event(self, event: Event, name: str) -> None: 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: if event == Event.Install:
tool_name = self.plugins[name]['tool_name'] self._installing_name = name
local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd" self._last_logged_pct = 0
remote_path = str(REMOTE_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd" logger.info(f"[{name}] 开始安装...")
self._install_requested.emit(local_path, remote_path) remote_path = self.plugins[name]['remote path']
self._install_requested.emit(local_dir, remote_path)
elif event == Event.Uninstall: elif event == Event.Uninstall:
tool_name = self.plugins[name]['tool_name'] self._uninstall_requested.emit(name, local_dir)
local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd"
self._uninstall_requested.emit(name, local_path)
elif event == Event.Update: elif event == Event.Update:
tool_name = self.plugins[name]['tool_name'] self._installing_name = name
local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd" remote_path = self.plugins[name]['remote path']
remote_path = str(REMOTE_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd" self._update_requested.emit(name, local_dir, remote_path)
self._update_requested.emit(name, local_path, remote_path)
+6 -4
View File
@@ -25,7 +25,7 @@ from .plugins_ui import Ui_FormPlugins
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QWidget, QListWidgetItem from PySide6.QtWidgets import QWidget, QListWidgetItem
from PySide6.QtCore import QSize,Signal from PySide6.QtCore import QSize, Signal
from .plugins_card import PluginsCard from .plugins_card import PluginsCard
from .plugin_registry import PluginRegistry from .plugin_registry import PluginRegistry
@@ -137,8 +137,12 @@ class Plugins(QWidget, Ui_FormPlugins):
# self.update_tool_card.emit(tools) # self.update_tool_card.emit(tools)
def on_update_plugins_card(self, plugins:dict): def on_update_plugins_card(self, plugins:dict):
# print(plugins)
for name in plugins.keys(): for name in plugins.keys():
if name in self.cards:
# 卡片已存在,只刷新状态(避免重复创建叠加)
self.cards[name].info = plugins[name]
self.cards[name].refresh()
else:
item = QListWidgetItem(self.listWidget) item = QListWidgetItem(self.listWidget)
plugin_card = PluginsCard(name, plugins[name]) plugin_card = PluginsCard(name, plugins[name])
plugin_card.send_event_signal.connect(self.plugin_registry.start_plugins_event) plugin_card.send_event_signal.connect(self.plugin_registry.start_plugins_event)
@@ -148,11 +152,9 @@ class Plugins(QWidget, Ui_FormPlugins):
self.listWidget.addItem(item) self.listWidget.addItem(item)
self.listWidget.setCurrentItem(item) self.listWidget.setCurrentItem(item)
self.listWidget.setItemWidget(item, plugin_card) self.listWidget.setItemWidget(item, plugin_card)
pass
def on_plugin_uninstalled(self, name: str) -> None: def on_plugin_uninstalled(self, name: str) -> None:
"""卸载完成后刷新对应卡片状态。""" """卸载完成后刷新对应卡片状态。"""
card = self.cards.get(name) card = self.cards.get(name)
if card is not None: if card is not None:
card.refresh() card.refresh()
+9 -5
View File
@@ -59,7 +59,7 @@ class PluginsCard(QFrame, Ui_FramePluginsCard):
def refresh(self) -> None: def refresh(self) -> None:
"""根据 info 刷新卡片显示与按钮状态。""" """根据 info 刷新卡片显示与按钮状态。"""
self.textBrowser.setMarkdown(self.to_card()) self.textBrowser.setMarkdown(self.to_card())
installed = self.info["local version"] is not None installed = self.info.get("local version") is not None
self.pushButtonInstall.setEnabled(not installed) self.pushButtonInstall.setEnabled(not installed)
self.pushButtonUninstall.setEnabled(installed) self.pushButtonUninstall.setEnabled(installed)
if installed: if installed:
@@ -70,13 +70,17 @@ class PluginsCard(QFrame, Ui_FramePluginsCard):
self.pushButtonUpdate.setEnabled(False) self.pushButtonUpdate.setEnabled(False)
def to_card(self): def to_card(self):
remote_ver = self.info.get('remote version')
local_ver = self.info.get('local version')
desc = self.info.get('remote description') or self.info.get('local description') or '暂无'
card = f"# {self.name}\n\n" card = f"# {self.name}\n\n"
card += f"**最新:** V{self.info['remote version']}\n\n" card += f"**最新:** V{remote_ver}\n\n" if remote_ver else f"**最新:** 暂无版本信息\n\n"
if self.info['local version'] is None: if local_ver is None:
card += f"**当前:** 未安装!\n\n" card += f"**当前:** 未安装!\n\n"
else: else:
card += f"**当前:** V{self.info['local version']}\n\n" card += f"**当前:** V{local_ver}\n\n"
card += f"**描述:** {self.info['remote description']}" card += f"**描述:** {desc}"
return card return card
def on_install_event(self): def on_install_event(self):
+28 -12
View File
@@ -13,6 +13,8 @@ Model Team Tools - 主程序入口
import os import os
import sys import sys
import logging import logging
import subprocess
import runtime_hook # noqa: F401 启动时把 plugins/*/ 加进 sys.path,必须在 core 之前
import core import core
import traceback import traceback
import importlib import importlib
@@ -20,7 +22,7 @@ import importlib
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from PySide6.QtCore import QProcess, QTimer from PySide6.QtCore import QTimer
from PySide6.QtGui import QIcon,QAction from PySide6.QtGui import QIcon,QAction
from PySide6.QtWidgets import QApplication, QMainWindow, QMenuBar, QStatusBar from PySide6.QtWidgets import QApplication, QMainWindow, QMenuBar, QStatusBar
from qt_material import apply_stylesheet from qt_material import apply_stylesheet
@@ -159,25 +161,39 @@ class MainWindow(QMainWindow, Ui_MainWindow):
def _restart_app(self) -> None: def _restart_app(self) -> None:
"""启动新进程并退出当前进程。 """启动新进程并退出当前进程。
sys.executable + sys.argv 同时兼容开发模式python.exe main.py 开发模式python.exe main.py
打包模式main.exe 打包模式main.exeNuitka standalone
用 subprocess.Popen + DETACHED_PROCESS 直接调 Win32 CreateProcess。
Nuitka standalone 下 sys.executable 指向不存在的原始 python.exe
此时改用 sys.argv[0](即 main.exe 自身)。
""" """
work_dir = os.path.dirname(os.path.abspath(sys.argv[0])) work_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
# 【重要】添加防递归标记,防止新进程启动后再次触发重启逻辑
args = sys.argv[:] args = sys.argv[:]
if '--restarting' not in args: if '--restarting' not in args:
args.append('--restarting') args.append('--restarting')
# 启动新进程 # Nuitka standalone 下 sys.executable 指向编译机器的 python.exe(分发环境不存在),
pid = QProcess.startDetached(sys.executable, args, work_dir) # 直接用 os.path.isfile 检测,不存在时回退到 sys.argv[0]main.exe 自身)。
if os.path.isfile(sys.executable):
if pid: cmd = [sys.executable] + args
print(f"新进程启动成功 (PID: {pid}),准备退出当前进程...")
# ✅ 关键修复:延迟 300ms 退出,确保新进程彻底“断奶”
QTimer.singleShot(300, QApplication.quit)
else: else:
print("重启失败,请检查路径或权限") cmd = [os.path.abspath(sys.argv[0])] + args[1:]
try:
proc = subprocess.Popen(
cmd,
cwd=work_dir,
creationflags=subprocess.DETACHED_PROCESS,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
logger.info(f"新进程已启动 (PID: {proc.pid}),当前进程即将退出")
QTimer.singleShot(100, QApplication.quit)
except Exception as e:
logger.error(f"启动新进程失败: {e}")
if __name__ == '__main__': if __name__ == '__main__':
+2326 -2246
View File
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
"""宿主启动时的 runtime hook。
宿主打包时通过 --runtime-hook 指定本文件,宿主 exe 启动时会在 main 之前执行本脚本。
作用:把 exe 旁边的 plugins/ 目录加进 sys.path
让插件自带的 .pyd + stdlib 子包(含 C 扩展)可以被 Python import 机制找到。
宿主打包命令示例(PyInstaller):
pyinstaller --onefile --runtime-hook tools/runtime_hook.py host_main.py
宿主打包命令示例(Nuitka):
nuitka --standalone --onefile --include-module=runtime_hook host_main.py
宿主部署目录布局:
host.exe
plugins/ ← 由 mil_sdk/tools/export_runtime.py 生成
└── mil/ ← 每插件一个子目录(zip 解压产物)
├── mil.cp311-*.pyd
├── openpyxl.cp311-*.pyd
├── et_xmlfile.cp311-*.pyd
├── xml/ ← 插件自带的 stdlib 子包(含 _elementtree.pyd
│ └── etree/
└── manifest.json
兼容旧扁平布局:plugins/ 直接含 manifest.json(过渡期)。
注意:本文件不依赖任何第三方包,只用 stdlibos / sys / json / pathlib)。
因为它在宿主 sys.path 配置好之前执行,不能 import 任何外部包。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _resolve_plugins_dir() -> Path | None:
"""定位 plugins/ 目录。
优先级:
1. 环境变量 MIL_PLUGIN_DIR
2. sys.executable 旁边的 plugins/onedir 模式)
3. sys._MEIPASS 旁边的 plugins/onefile 模式解压目录)
"""
# 1) 环境变量
env = os.environ.get("MIL_PLUGIN_DIR")
if env:
p = Path(env)
if p.is_dir():
return p.resolve()
# 2) onedirexe 旁边的 plugins/
exe_dir = Path(sys.executable).resolve().parent
plugins = exe_dir / "plugins"
if plugins.is_dir():
return plugins.resolve()
# 3) onefilePyInstaller 解压目录旁边的 plugins/
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
plugins = Path(meipass).parent / "plugins"
if plugins.is_dir():
return plugins.resolve()
return None
def _check_python_version(manifest_path: Path) -> None:
"""校验插件的 Python 版本与当前解释器兼容(minor 版本必须一致)。"""
import json
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
return # manifest 损坏不阻止启动,让 import 错误自己暴露
plugin_py = manifest.get("python_version", "")
host_py = f"{sys.version_info.major}.{sys.version_info.minor}"
if not plugin_py.startswith(host_py):
sys.stderr.write(
f"[mil] WARNING: 插件 Python 版本({plugin_py}) 与宿主({host_py}) 可能不兼容\n"
)
def _insert_path(path: Path) -> None:
"""把 path 加到 sys.path[0],已存在则跳过。"""
p = str(path)
if p not in sys.path:
sys.path.insert(0, p)
def setup() -> None:
"""把 plugins/ 或 plugins/*/ 子目录加进 sys.path 最前面。
兼容两种布局:
- 旧扁平:plugins/ 直接含 manifest.json(过渡期)
- 新子目录:plugins/<插件名>/ 各自含 manifest.json
"""
plugins_dir = _resolve_plugins_dir()
if plugins_dir is None:
return
# 旧扁平布局:plugins/ 直接有 manifest.json
if (plugins_dir / "manifest.json").exists():
_check_python_version(plugins_dir / "manifest.json")
_insert_path(plugins_dir)
return
# 新子目录布局:逐个扫描 plugins/*/
for sub in plugins_dir.iterdir():
if not sub.is_dir():
continue
if (sub / "manifest.json").exists():
_check_python_version(sub / "manifest.json")
_insert_path(sub)
# 模块加载时立即执行(PyInstaller runtime hook 的约定)
setup()
+8
View File
@@ -0,0 +1,8 @@
{
"DataPath": "",
"FilePath": "",
"AddTimeEn": true,
"GeratePath": true,
"CurrProject": "",
"ItemConfigs": {}
}