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:
@@ -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
|
|
||||||
|
|||||||
@@ -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,11 +142,17 @@ 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)
|
||||||
self.listWidget.setCurrentItem(item)
|
self.listWidget.setCurrentItem(item)
|
||||||
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()
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -125,16 +129,60 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
|||||||
action = QAction(name,self)
|
action = QAction(name,self)
|
||||||
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())
|
||||||
Reference in New Issue
Block a user