fix: 修复插件卸载后重启失败问题,增加安装进度日志
This commit is contained in:
@@ -31,4 +31,7 @@ htmlcov/
|
|||||||
config.json
|
config.json
|
||||||
*.qm
|
*.qm
|
||||||
*.ts
|
*.ts
|
||||||
|
*.xml
|
||||||
*.trae/
|
*.trae/
|
||||||
|
/plugins
|
||||||
|
/logs
|
||||||
@@ -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
@@ -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>.zip(export_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.pyd,C 扩展入口函数为
|
||||||
|
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)
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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.exe(Nuitka 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
File diff suppressed because it is too large
Load Diff
+118
@@ -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(过渡期)。
|
||||||
|
|
||||||
|
注意:本文件不依赖任何第三方包,只用 stdlib(os / 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) onedir:exe 旁边的 plugins/
|
||||||
|
exe_dir = Path(sys.executable).resolve().parent
|
||||||
|
plugins = exe_dir / "plugins"
|
||||||
|
if plugins.is_dir():
|
||||||
|
return plugins.resolve()
|
||||||
|
|
||||||
|
# 3) onefile:PyInstaller 解压目录旁边的 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()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"DataPath": "",
|
||||||
|
"FilePath": "",
|
||||||
|
"AddTimeEn": true,
|
||||||
|
"GeratePath": true,
|
||||||
|
"CurrProject": "",
|
||||||
|
"ItemConfigs": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user