--- before/wechat_gui_qt.py +++ C:\kefu\wechat_rpa\wechat_gui_qt.py @@ -66,6 +66,7 @@ QListWidget, QListWidgetItem, QMainWindow, + QMenu, QMessageBox, QPlainTextEdit, QProgressBar, @@ -78,6 +79,7 @@ QStyle, QStyleOption, QStyleOptionButton, + QSystemTrayIcon, QTableWidget, QTableWidgetItem, QTextEdit, @@ -9255,6 +9257,40 @@ "正在停止" if state == "stopping" else "已停止") +class _ApplicationQuitFilter(QObject): + """An explicit application quit must bypass window close-to-tray.""" + def __init__(self, window, parent=None): + super().__init__(parent) + self.window = window + + def eventFilter(self, watched, event) -> bool: + if event.type() == QEvent.Type.Quit: + self.window._exit_requested = True + return False + + +def _consume_instance_activation(app, guard, window=None) -> None: + # The login/update dialog runs a nested event loop before MainWindow is + # constructed. Do not consume a launch request until a window exists. + target = app.activeModalWidget() + if target is None: + target = window or app.activeWindow() + if target is None: + target = next((widget for widget in app.topLevelWidgets() + if isinstance(widget, QDialog) and widget.isVisible()), None) + if target is None or getattr(target, "_exit_requested", False): + return + if not guard.consume_activation(): + return + if isinstance(target, MainWindow): + target.restore_from_tray() + else: + target.setWindowState(target.windowState() & ~Qt.WindowMinimized) + target.show() + target.raise_() + target.activateWindow() + + class MainWindow(QMainWindow): desktopSessionChecked = Signal(str, object) wechatDatabaseLoaded = Signal(object) @@ -9334,11 +9370,7 @@ except Exception: pass - self.capsule.hide() - self.showNormal() - self.show() - self.raise_() - self.activateWindow() + self.restore_from_tray() QMessageBox.warning( self, "账号已被强制下线", @@ -9349,7 +9381,7 @@ account = ensure_desktop_login(self) if not account: - QApplication.quit() + self.request_exit() return self._set_account_menu_title(account) self._desktop_session_state = "valid" @@ -9379,7 +9411,7 @@ backend_client.desktop_logout() finally: - QApplication.quit() + self.request_exit() def __init__(self): super().__init__() @@ -9493,6 +9525,7 @@ root_layout.addWidget(self.console_shell, 1) self.capsule = CapsuleWindow() + self._initialize_system_tray() self._connect_signals() self.business_refresh_timer = QTimer(self) self.business_refresh_timer.setSingleShot(True) @@ -11343,12 +11376,18 @@ self._schedule_console_push(urgent=True) def changeEvent(self, event) -> None: + if (event.type() == QEvent.Type.WindowStateChange and not self.isMinimized() + and not getattr(self, "_tray_hidden", False)): + self._close_minimized = False if event.type() == QEvent.Type.WindowStateChange and hasattr(self, "_scale_timer"): self._scale_timer.start() if ( event.type() == QEvent.Type.WindowStateChange and self.isMinimized() and getattr(self, "_running", False) + and not getattr(self, "_tray_hidden", False) + and not getattr(self, "_close_minimized", False) + and not getattr(self, "_exit_requested", False) and bool(self.runtime_settings.get("minimize_on_close", True)) and not self.capsule.isVisible() and not self._capsule_minimize_pending @@ -11382,6 +11421,9 @@ self._capsule_minimize_pending = False if ( not self._running + or getattr(self, "_tray_hidden", False) + or getattr(self, "_close_minimized", False) + or getattr(self, "_exit_requested", False) or not self.isMinimized() or self.capsule.isVisible() ): @@ -11399,6 +11441,9 @@ was_maximized: bool | None = None, portal_was_visible: bool | None = None, ) -> None: + if (getattr(self, "_tray_hidden", False) or getattr(self, "_close_minimized", False) + or getattr(self, "_exit_requested", False)): + return if self.capsule.isVisible(): return self._saved_geometry = ( @@ -11432,6 +11477,8 @@ self.hide() def expand_console(self) -> None: + self._tray_hidden = False + self._close_minimized = False self.capsule.hide() if self._saved_geometry is not None: self.restoreGeometry(self._saved_geometry) @@ -11446,11 +11493,14 @@ self._console_was_visible = False if getattr(self, "_portal_was_visible", False) and self.stack.currentIndex() == 0: def restore_portal() -> None: + if (getattr(self, "_exit_requested", False) + or getattr(self, "_tray_hidden", False) or not self.isVisible()): + return if self.portal_page.view is not None: self.portal_page.view.show() self.portal_page.view.setFocus(Qt.OtherFocusReason) - QTimer.singleShot(80, restore_portal) + QTimer.singleShot(80, self, restore_portal) self._portal_was_visible = False def _finish_thread(self, final_state: str) -> None: @@ -11541,7 +11591,7 @@ self.queue_page.apply_visual_state(data) except KeyboardInterrupt: # Qt 默认会把 Ctrl+C 打断留在当前槽函数中并打印堆栈;转为正常关闭。 - QTimer.singleShot(0, self.close) + QTimer.singleShot(0, self.request_exit) return except queue.Empty: pass @@ -11552,34 +11602,153 @@ self.dashboard_page.timer.setText(f"运行时长 {runtime}") self.capsule.timer.setText(runtime) - def closeEvent(self, event) -> None: - if ( - self._running - and bool(self.runtime_settings.get("keep_background", True)) - and not self.capsule.isVisible() - ): - event.ignore() - self.enter_capsule_mode() - return - self.save_runtime_settings() + def _initialize_system_tray(self) -> None: + self._tray_icon = None + self._tray_hidden = False + self._close_minimized = False + self._exit_requested = False + self._shutdown_complete = False + app = QApplication.instance() + self._quit_filter = _ApplicationQuitFilter(self, app) + app.installEventFilter(self._quit_filter) + app.aboutToQuit.connect(self._shutdown_application) + if "--qt-smoke-test" not in sys.argv: + self._ensure_system_tray() + + def _ensure_system_tray(self) -> bool: + if not QSystemTrayIcon.isSystemTrayAvailable(): + return False + if getattr(self, "_tray_icon", None) is None: + tray = QSystemTrayIcon(_app_icon(), self) + tray.setToolTip(WIN_TITLE) + menu = QMenu(self) + open_action = menu.addAction("打开主界面") + open_action.triggered.connect(self.restore_from_tray) + menu.addSeparator() + exit_action = menu.addAction("退出程序") + exit_action.triggered.connect(self.request_exit) + tray.setContextMenu(menu) + tray.activated.connect(self._tray_activated) + tray.messageClicked.connect(self.restore_from_tray) + self._tray_menu = menu + self._tray_icon = tray + self._tray_icon.show() + return self._tray_icon.isVisible() + + def _tray_activated(self, reason) -> None: + if reason in (QSystemTrayIcon.ActivationReason.Trigger, + QSystemTrayIcon.ActivationReason.DoubleClick): + self.restore_from_tray() + + def _hide_to_system_tray(self) -> None: + if not self._ensure_system_tray(): + # Without a tray (e.g. Explorer restarting), remain reachable on + # the taskbar and suppress the listener's floating-capsule path. + if not self.isVisible() or self.capsule.isVisible(): + self.expand_console() + self._close_minimized = True + self._tray_hidden = False + self.capsule.hide() + self.showMinimized() + return + self._tray_hidden = True + self._close_minimized = False + self._capsule_minimize_pending = False + if self.isVisible(): + self._saved_geometry = self.saveGeometry() + self._was_maximized = bool(self.windowState() & Qt.WindowMaximized) + console = getattr(self, "console_shell", None) + if console is not None: + self._console_was_visible = console.isVisible() + console.hide() + portal = self.portal_page.view + self._portal_was_visible = bool(portal is not None and portal.isVisible()) + if self._portal_was_visible: + portal.hide() + QApplication.processEvents() + self.capsule.hide() + self.hide() + + def restore_from_tray(self) -> None: + if getattr(self, "_exit_requested", False): + return + if not self.isVisible() or self.isMinimized() or self.capsule.isVisible(): + if self.isMinimized() and not getattr(self, "_tray_hidden", False): + self._was_maximized = bool(self.windowState() & Qt.WindowMaximized) + self.expand_console() + else: + self.raise_() + self.activateWindow() + + def request_exit(self) -> None: + self._exit_requested = True + self.close() + QApplication.quit() + + def _shutdown_application(self) -> None: + if getattr(self, "_shutdown_complete", False): + return + self._exit_requested = True + self._shutdown_complete = True self._running = False + for name in ("queue_timer", "queue_page_timer", "backend_sync_timer", + "desktop_session_timer", "business_refresh_timer", + "_console_push_timer", "_scale_timer"): + timer = getattr(self, name, None) + if timer is not None: + timer.stop() + try: + self.save_runtime_settings() + except Exception: + pass if bool(self.runtime_settings.get("clear_clipboard_on_exit", True)): try: QApplication.clipboard().clear() except Exception: pass - if self._thread is not None and self._thread.is_alive(): - self._thread.stop() - if sys.stdout is self._stdout_proxy: + worker = getattr(self, "_thread", None) + if worker is not None and worker.is_alive(): + worker.stop() + proxy = getattr(self, "_stdout_proxy", None) + if proxy is not None and sys.stdout is proxy: sys.stdout = self._original_stdout + if proxy is not None: + try: + proxy.close() + except Exception: + pass + self._stdout_proxy = None + tray = getattr(self, "_tray_icon", None) + if tray is not None: + tray.hide() self.capsule.close() try: + from archive_auto_backup import stop_auto_backup + stop_auto_backup() + except Exception: + pass + try: from dsh_agent import close_desk - close_desk() except Exception: pass + + def closeEvent(self, event) -> None: + if not hasattr(self, "runtime_settings"): + event.accept() + return + app = QApplication.instance() + if app is not None and app.isSavingSession(): + self._exit_requested = True + if (not getattr(self, "_exit_requested", False) + and bool(self.runtime_settings.get("minimize_on_close", True))): + event.ignore() + self._hide_to_system_tray() + return + self._shutdown_application() event.accept() + QApplication.quit() + def _app_icon() -> QIcon: @@ -11610,8 +11779,7 @@ if shutdown_requested: return shutdown_requested = True - QTimer.singleShot(0, window.close) - QTimer.singleShot(0, app.quit) + QTimer.singleShot(0, window.request_exit) handled = [signal.SIGINT] if hasattr(signal, "SIGBREAK"): @@ -12042,6 +12210,41 @@ if "--packaging-self-check" in sys.argv: raise SystemExit(run_packaging_self_check(app)) + if "--qt-smoke-test" in sys.argv: + return _run_desktop_session(app, [None]) + + from desktop_instance import DesktopInstanceGuard + guard = None + activation_timer = None + try: + try: + guard = DesktopInstanceGuard() + primary = guard.acquire() + except OSError as exc: + QMessageBox.critical(None, "无法启动客户端", f"无法建立单实例保护,请稍后重试。\n{exc}") + return + if not primary: + try: + guard.request_activation() + except OSError: + pass + return + window_holder = [None] + activation_timer = QTimer(app) + activation_timer.setInterval(250) + activation_timer.timeout.connect( + lambda: _consume_instance_activation(app, guard, window_holder[0])) + activation_timer.start() + return _run_desktop_session(app, window_holder) + finally: + if activation_timer is not None: + activation_timer.stop() + activation_timer.deleteLater() + if guard is not None: + guard.close() + + +def _run_desktop_session(app, window_holder) -> None: if "--qt-smoke-test" not in sys.argv: from desktop_login import ensure_desktop_login @@ -12049,6 +12252,9 @@ return window = MainWindow() + window_holder[0] = window + if "--qt-smoke-test" not in sys.argv: + app.setQuitOnLastWindowClosed(False) console_shutdown = install_console_shutdown_handler(app, window) window.show() QTimer.singleShot(160, window.portal_page.ensure_view)