diff --git a/core/plugin/plugin_registry.py b/core/plugin/plugin_registry.py index 20b5103..de0e5e3 100644 --- a/core/plugin/plugin_registry.py +++ b/core/plugin/plugin_registry.py @@ -21,10 +21,24 @@ from PySide6.QtCore import QObject, Signal, Slot, QThread, QTimer LOCAL_PLUGINS_PATH = Path("./plugins") REMOTE_PLUGINS_PATH = Path('Y:/SE/xufeifei/plugins') +UNINSTALL_PENDING_FILE = LOCAL_PLUGINS_PATH / ".uninstall_pending" 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): Update = auto() Install = auto() @@ -41,6 +55,7 @@ class PluginWorker(QObject): remote_discovered = Signal(list) install_progress = Signal(int, int) install_finished = Signal(bool, str) + uninstall_finished = Signal(str, bool, str) @Slot(str) def do_discover_local(self, plugins_dir: str) -> None: @@ -77,6 +92,24 @@ class PluginWorker(QObject): logger.error(f"{remote_path}安装失败{e.args}") 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 def _ensure_dir(plugins_dir: Path) -> bool: if not os.path.exists(plugins_dir): @@ -119,15 +152,21 @@ class PluginRegistry(QObject): plugins_loader_signal = Signal(dict) update_plugins_card_signal = Signal(dict) + uninstall_completed_signal = Signal(str) _discover_local_requested = Signal(str) _discover_remote_requested = Signal(str) _install_requested = Signal(str, str) + _uninstall_requested = Signal(str, str) def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent) self.plugins: dict = {} + # 启动时先清理上次遗留的待删清单(此时相关 .pyd 尚未 import, + # 且 worker 线程未启动,无并发文件访问) + self._process_pending_uninstalls() + self._worker = PluginWorker() self._thread = QThread() self._worker.moveToThread(self._thread) @@ -135,15 +174,54 @@ class PluginRegistry(QObject): self._discover_local_requested.connect(self._worker.do_discover_local) self._discover_remote_requested.connect(self._worker.do_discover_remote) 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.remote_discovered.connect(self._on_remote_discovered) 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() # 延迟到事件循环启动后触发,保证所有接收方先 connect 后 emit 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: self._discover_local_requested.emit(str(LOCAL_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: for item in results: 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: self.plugins[name] = {} - module = importlib.import_module(item["tool_name"]) - self.plugins[name]["obj"] = module.create_plugin() + self.plugins[name]["obj"] = obj self.plugins[name]["local version"] = item["version"] self.plugins_loader_signal.emit(self.plugins) @@ -183,6 +266,18 @@ class PluginRegistry(QObject): else: 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: if event == Event.Install: tool_name = self.plugins[name]['tool_name'] @@ -192,5 +287,4 @@ class PluginRegistry(QObject): elif event == Event.Uninstall: tool_name = self.plugins[name]['tool_name'] local_path = str(LOCAL_PLUGINS_PATH) + '\\' + f"{tool_name}.pyd" - # os. - pass + self._uninstall_requested.emit(name, local_path) diff --git a/core/plugin/plugins.py b/core/plugin/plugins.py index 4913230..87341d1 100644 --- a/core/plugin/plugins.py +++ b/core/plugin/plugins.py @@ -64,10 +64,12 @@ class Plugins(QWidget, Ui_FormPlugins): self.setupUi(self) self.plugin_registry = plugin_registry + self.cards: dict = {} self.initUI() self.load_stylesheet() 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: """ @@ -140,11 +142,17 @@ class Plugins(QWidget, Ui_FormPlugins): item = QListWidgetItem(self.listWidget) plugin_card = PluginsCard(name, plugins[name]) 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)) self.listWidget.addItem(item) self.listWidget.setCurrentItem(item) self.listWidget.setItemWidget(item, plugin_card) pass + + def on_plugin_uninstalled(self, name: str) -> None: + """卸载完成后刷新对应卡片状态。""" + card = self.cards.get(name) + if card is not None: + card.refresh() \ No newline at end of file diff --git a/core/plugin/plugins_card.py b/core/plugin/plugins_card.py index 45bfc22..598d3d7 100644 --- a/core/plugin/plugins_card.py +++ b/core/plugin/plugins_card.py @@ -28,17 +28,24 @@ class PluginsCard(QFrame, Ui_FramePluginsCard): def initUI(self): self.textBrowser.setReadOnly(True) - self.textBrowser.setMarkdown(self.to_card()) - if self.info["local version"] is None: - 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.refresh() self.pushButtonInstall.clicked.connect(self.on_install_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): card = f"# {self.name}\n\n" card += f"**最新:** V{self.info['remote version']}\n\n" diff --git a/main.py b/main.py index 2910eee..9d32c49 100644 --- a/main.py +++ b/main.py @@ -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()) \ No newline at end of file