Files
kefu/deploy/single-instance-tray-20260917/source-qt.diff
T
2026-09-21 10:34:06 +08:00

402 lines
14 KiB
Diff

--- 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)