feat(plugin): 实现插件卸载功能与自重启机制

目的:补齐 Event.Uninstall 空实现,打通卸载全链路。

背景:原 plugin_registry.py 的 Uninstall 分支为空 pass;运行时直接
os.remove 删 .pyd 触发 WinError 5(Windows 文件锁,.pyd 已被 import)。

方案:
- 卸载流程分层清理:Worker 写待删清单 → Registry 清字典/obj →
  main 清 toolBar/stackedWidget → plugins_card 刷新为未安装
- .pyd 文件删除延迟到下次启动 _process_pending_uninstalls,
  在 worker 线程启动前、import 之前执行,避开文件锁
- 卸载后自动重启:QProcess.startDetached + QApplication.quit,
  兼容开发模式(python.exe)与打包模式(main.exe)
- _remove_with_retry 应对自重启时原进程句柄未释放的暂态竞争
- _on_local_discovered 加异常隔离,单插件失败不拖垮初始化
- QThread 生命周期管理:aboutToQuit → shutdown(quit+wait),
  finished → worker.deleteLater,消除 Destroyed 警告
- plugins_card 抽 refresh() 方法,统一卡片状态刷新

影响:卸载功能可用;自重启在命令行/打包模式正常,调试器环境
可能因子进程管理闪退(不影响交付)。
This commit is contained in:
2026-07-20 13:15:16 +08:00
parent aaad6b8aa6
commit 51e30d41c6
4 changed files with 173 additions and 16 deletions
+51 -3
View File
@@ -20,7 +20,7 @@ import importlib
from pathlib import Path
from typing import Optional
# from PySide6.QtCore import QAction
from PySide6.QtCore import QProcess, QTimer
from PySide6.QtGui import QIcon,QAction
from PySide6.QtWidgets import QApplication, QMainWindow, QMenuBar, QStatusBar
from qt_material import apply_stylesheet
@@ -53,6 +53,10 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.plugin_registry = core.PluginRegistry()
self.plugins = core.Plugins(self.plugin_registry)
self.plugin_registry.plugins_loader_signal.connect(self.on_plugins_loader)
self.plugin_registry.uninstall_completed_signal.connect(self.on_plugin_uninstalled)
self.tool_actions: dict = {}
self.plugin_widgets: dict = {}
self.initUI()
@@ -125,16 +129,60 @@ class MainWindow(QMainWindow, Ui_MainWindow):
action = QAction(name,self)
self.toolBar.addAction(action)
action.triggered.connect(lambda checked, action=action: self.on_action_triggered(action))
self.stackedWidget.addWidget(plugins[name]['obj'])
widget = plugins[name]['obj']
self.stackedWidget.addWidget(widget)
self.tool_actions[name] = action
self.plugin_widgets[name] = widget
def on_action_triggered(self, action:QAction):
print(action.text())
self.stackedWidget.setCurrentWidget(self.plugin_registry.plugins[action.text()]['obj'])
def on_plugin_uninstalled(self, name: str) -> None:
"""卸载完成后清理主窗口工具栏与堆栈窗口中的对应项,并自动重启。
.pyd 已被进程加载,运行时无法删除(Windows 文件锁),因此重启进程:
新进程启动时会先执行待删清单,在 import 之前删除 .pyd 文件。
"""
action = self.tool_actions.pop(name, None)
if action is not None:
self.toolBar.removeAction(action)
action.deleteLater()
widget = self.plugin_widgets.pop(name, None)
if widget is not None:
self.stackedWidget.removeWidget(widget)
widget.deleteLater()
self.statusbar.showMessage(f"{name} 已卸载,应用即将重启以完成清理...", 1500)
QTimer.singleShot(1000, self._restart_app)
def _restart_app(self) -> None:
"""启动新进程并退出当前进程。
sys.executable + sys.argv 同时兼容开发模式(python.exe main.py
与打包模式(main.exe)。
"""
work_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
# 【重要】添加防递归标记,防止新进程启动后再次触发重启逻辑
args = sys.argv[:]
if '--restarting' not in args:
args.append('--restarting')
# 启动新进程
pid = QProcess.startDetached(sys.executable, args, work_dir)
if pid:
print(f"新进程启动成功 (PID: {pid}),准备退出当前进程...")
# ✅ 关键修复:延迟 300ms 退出,确保新进程彻底“断奶”
QTimer.singleShot(300, QApplication.quit)
else:
print("重启失败,请检查路径或权限")
if __name__ == '__main__':
app = QApplication(sys.argv)
main: MainWindow = MainWindow()
app.aboutToQuit.connect(main.plugin_registry.shutdown)
main.show()
sys.exit(app.exec())