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
+98 -4
View File
@@ -21,10 +21,24 @@ from PySide6.QtCore import QObject, Signal, Slot, QThread, QTimer
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"
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
class Event(Enum): class Event(Enum):
Update = auto() Update = auto()
Install = auto() Install = auto()
@@ -41,6 +55,7 @@ class PluginWorker(QObject):
remote_discovered = Signal(list) remote_discovered = Signal(list)
install_progress = Signal(int, int) install_progress = Signal(int, int)
install_finished = Signal(bool, str) install_finished = Signal(bool, str)
uninstall_finished = Signal(str, bool, str)
@Slot(str) @Slot(str)
def do_discover_local(self, plugins_dir: str) -> None: def do_discover_local(self, plugins_dir: str) -> None:
@@ -77,6 +92,24 @@ class PluginWorker(QObject):
logger.error(f"{remote_path}安装失败{e.args}") logger.error(f"{remote_path}安装失败{e.args}")
self.install_finished.emit(False, str(e)) self.install_finished.emit(False, str(e))
@Slot(str, str)
def do_uninstall(self, name: str, local_path: str) -> None:
"""运行时无法删除已加载的 .pyd(Windows 文件锁),仅记入待删清单。
真正的文件删除由 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")
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,15 +152,21 @@ 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)
_discover_local_requested = Signal(str) _discover_local_requested = Signal(str)
_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)
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 = {}
# 启动时先清理上次遗留的待删清单(此时相关 .pyd 尚未 import
# 且 worker 线程未启动,无并发文件访问)
self._process_pending_uninstalls()
self._worker = PluginWorker() self._worker = PluginWorker()
self._thread = QThread() self._thread = QThread()
self._worker.moveToThread(self._thread) self._worker.moveToThread(self._thread)
@@ -135,15 +174,54 @@ class PluginRegistry(QObject):
self._discover_local_requested.connect(self._worker.do_discover_local) self._discover_local_requested.connect(self._worker.do_discover_local)
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._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)
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._thread.finished.connect(self._worker.deleteLater)
self._thread.start() self._thread.start()
# 延迟到事件循环启动后触发,保证所有接收方先 connect 后 emit # 延迟到事件循环启动后触发,保证所有接收方先 connect 后 emit
QTimer.singleShot(0, self._start_discovery) QTimer.singleShot(0, self._start_discovery)
def shutdown(self) -> None:
"""优雅停止工作线程:请求事件循环退出并等待真正结束。
必须在 QThread 析构前调用,否则触发
"QThread: Destroyed while thread is still running" 警告。
"""
self._thread.quit()
self._thread.wait(3000)
@staticmethod
def _process_pending_uninstalls() -> None:
"""启动时执行待删清单:删除 .pyd 文件并清空清单。
必须在任何 importlib.import_module 之前调用,此时 .pyd 未被加载,
Windows 文件锁不会触发 WinError 5。但自重启场景下,原进程可能尚未
完全释放文件句柄,因此删除失败时短暂重试。
"""
if not UNINSTALL_PENDING_FILE.exists():
return
lines = UNINSTALL_PENDING_FILE.read_text(encoding='utf-8').splitlines()
pending = [ln.strip() for ln in lines if ln.strip()]
failed: list = []
for tool_name in pending:
target = LOCAL_PLUGINS_PATH / f"{tool_name}.pyd"
if not target.exists():
continue
if _remove_with_retry(target):
logger.info(f"已删除插件文件: {target}")
else:
failed.append(tool_name)
logger.error(f"删除插件文件失败(重试后仍被占用): {target}")
if failed:
UNINSTALL_PENDING_FILE.write_text("\n".join(failed) + "\n", encoding='utf-8')
else:
UNINSTALL_PENDING_FILE.unlink()
def _start_discovery(self) -> None: def _start_discovery(self) -> None:
self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH)) self._discover_local_requested.emit(str(LOCAL_PLUGINS_PATH))
self._discover_remote_requested.emit(str(REMOTE_PLUGINS_PATH)) self._discover_remote_requested.emit(str(REMOTE_PLUGINS_PATH))
@@ -152,10 +230,15 @@ class PluginRegistry(QObject):
def _on_local_discovered(self, results: list) -> None: def _on_local_discovered(self, results: list) -> None:
for item in results: for item in results:
name = item["name"] name = item["name"]
try:
module = importlib.import_module(item["tool_name"])
obj = module.create_plugin()
except Exception as e:
logger.error(f"本地插件 {name} 实例化失败,跳过: {e}")
continue
if name not in self.plugins: if name not in self.plugins:
self.plugins[name] = {} self.plugins[name] = {}
module = importlib.import_module(item["tool_name"]) self.plugins[name]["obj"] = obj
self.plugins[name]["obj"] = module.create_plugin()
self.plugins[name]["local version"] = item["version"] self.plugins[name]["local version"] = item["version"]
self.plugins_loader_signal.emit(self.plugins) self.plugins_loader_signal.emit(self.plugins)
@@ -183,6 +266,18 @@ class PluginRegistry(QObject):
else: else:
logger.error(message) logger.error(message)
@Slot(str, bool, str)
def _on_uninstall_finished(self, name: str, success: bool, message: str) -> None:
if not success:
logger.error(message)
return
logger.info(message)
info = self.plugins.get(name)
if info is not None:
info.pop("obj", None)
info["local version"] = None
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:
if event == Event.Install: if event == Event.Install:
tool_name = self.plugins[name]['tool_name'] tool_name = self.plugins[name]['tool_name']
@@ -192,5 +287,4 @@ class PluginRegistry(QObject):
elif event == Event.Uninstall: elif event == Event.Uninstall:
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"
# os. self._uninstall_requested.emit(name, local_path)
pass
+9 -1
View File
@@ -64,10 +64,12 @@ class Plugins(QWidget, Ui_FormPlugins):
self.setupUi(self) self.setupUi(self)
self.plugin_registry = plugin_registry self.plugin_registry = plugin_registry
self.cards: dict = {}
self.initUI() self.initUI()
self.load_stylesheet() self.load_stylesheet()
self.plugin_registry.update_plugins_card_signal.connect(self.on_update_plugins_card) self.plugin_registry.update_plugins_card_signal.connect(self.on_update_plugins_card)
self.plugin_registry.uninstall_completed_signal.connect(self.on_plugin_uninstalled)
def initUI(self) -> None: def initUI(self) -> None:
""" """
@@ -140,7 +142,7 @@ class Plugins(QWidget, Ui_FormPlugins):
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)
self.cards[name] = plugin_card
item.setSizeHint(QSize(plugin_card.sizeHint().width(), 200)) item.setSizeHint(QSize(plugin_card.sizeHint().width(), 200))
self.listWidget.addItem(item) self.listWidget.addItem(item)
@@ -148,3 +150,9 @@ class Plugins(QWidget, Ui_FormPlugins):
self.listWidget.setItemWidget(item, plugin_card) self.listWidget.setItemWidget(item, plugin_card)
pass pass
def on_plugin_uninstalled(self, name: str) -> None:
"""卸载完成后刷新对应卡片状态。"""
card = self.cards.get(name)
if card is not None:
card.refresh()
+15 -8
View File
@@ -28,17 +28,24 @@ class PluginsCard(QFrame, Ui_FramePluginsCard):
def initUI(self): def initUI(self):
self.textBrowser.setReadOnly(True) self.textBrowser.setReadOnly(True)
self.textBrowser.setMarkdown(self.to_card())
if self.info["local version"] is None: self.refresh()
self.pushButtonUpdate.setEnabled(False)
self.pushButtonUninstall.setEnabled(False)
else:
self.pushButtonInstall.setEnabled(False)
if self.info['remote version'] <= self.info['local version']:
self.pushButtonUpdate.setEnabled(False)
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)
def refresh(self) -> None:
"""根据 info 刷新卡片显示与按钮状态。"""
self.textBrowser.setMarkdown(self.to_card())
installed = self.info["local version"] is not None
self.pushButtonInstall.setEnabled(not installed)
self.pushButtonUninstall.setEnabled(installed)
if installed:
self.pushButtonUpdate.setEnabled(
self.info['remote version'] > self.info['local version']
)
else:
self.pushButtonUpdate.setEnabled(False)
def to_card(self): def to_card(self):
card = f"# {self.name}\n\n" card = f"# {self.name}\n\n"
card += f"**最新:** V{self.info['remote version']}\n\n" card += f"**最新:** V{self.info['remote version']}\n\n"
+50 -2
View File
@@ -20,7 +20,7 @@ import importlib
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
# from PySide6.QtCore import QAction from PySide6.QtCore import QProcess, 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
@@ -53,6 +53,10 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.plugin_registry = core.PluginRegistry() self.plugin_registry = core.PluginRegistry()
self.plugins = core.Plugins(self.plugin_registry) self.plugins = core.Plugins(self.plugin_registry)
self.plugin_registry.plugins_loader_signal.connect(self.on_plugins_loader) 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() self.initUI()
@@ -126,15 +130,59 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.toolBar.addAction(action) self.toolBar.addAction(action)
action.triggered.connect(lambda checked, action=action: self.on_action_triggered(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): def on_action_triggered(self, action:QAction):
print(action.text()) print(action.text())
self.stackedWidget.setCurrentWidget(self.plugin_registry.plugins[action.text()]['obj']) 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__': if __name__ == '__main__':
app = QApplication(sys.argv) app = QApplication(sys.argv)
main: MainWindow = MainWindow() main: MainWindow = MainWindow()
app.aboutToQuit.connect(main.plugin_registry.shutdown)
main.show() main.show()
sys.exit(app.exec()) sys.exit(app.exec())