from pathlib import Path import os import difflib ARTIFACT = Path(__file__).parent TRAY_METHODS = ''' 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. 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 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 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() ''' QUIT_FILTER = '''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() ''' MAIN_GUARD = ''' if "--qt-smoke-test" in sys.argv: return _run_desktop_session(app, [None]) from desktop_instance import DesktopInstanceGuard guard = DesktopInstanceGuard() activation_timer = None try: try: 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() guard.close() def _run_desktop_session(app, window_holder) -> None: ''' for root in (Path('C:/wechat_rpa'), Path('C:/kefu/wechat_rpa')): path = root / 'wechat_gui_qt.py' old = path.read_text(encoding='utf-8') backup = root / 'backups/single-instance-tray-20260917/wechat_gui_qt.py' backup.parent.mkdir(parents=True, exist_ok=True) if not backup.exists(): backup.write_text(old, encoding='utf-8') s = old assert 'def _initialize_system_tray' not in s s = s.replace(' QMainWindow,\n', ' QMainWindow,\n QMenu,\n') s = s.replace(' QStyleOptionButton,\n', ' QStyleOptionButton,\n QSystemTrayIcon,\n') s = s.replace(' self.capsule = CapsuleWindow()\n', ' self.capsule = CapsuleWindow()\n self._initialize_system_tray()\n', 1) start = s.index(' def closeEvent(self, event) -> None:', s.index('class MainWindow(')) end = s.index('\ndef _app_icon()', start) s = s[:start] + TRAY_METHODS + '\n' + s[end:] s = s.replace(' and getattr(self, "_running", False)\n', ' and getattr(self, "_running", False)\n and not getattr(self, "_tray_hidden", False)\n and not getattr(self, "_close_minimized", False)\n and not getattr(self, "_exit_requested", False)\n', 1) s = s.replace(' not self._running\n or not self.isMinimized()', ' not self._running\n or getattr(self, "_tray_hidden", False)\n or getattr(self, "_close_minimized", False)\n or getattr(self, "_exit_requested", False)\n or not self.isMinimized()', 1) marker = ' def enter_capsule_mode(' begin = s.index(marker) pos = s.index(' if self.capsule.isVisible():', begin) s = s[:pos] + ' if (getattr(self, "_tray_hidden", False) or getattr(self, "_close_minimized", False)\n or getattr(self, "_exit_requested", False)):\n return\n' + s[pos:] s = s.replace(' def expand_console(self) -> None:\n self.capsule.hide()', ' def expand_console(self) -> None:\n self._tray_hidden = False\n self._close_minimized = False\n self.capsule.hide()', 1) s = s.replace(' QTimer.singleShot(0, self.close)', ' QTimer.singleShot(0, self.request_exit)') s = s.replace(' QTimer.singleShot(0, window.close)\n QTimer.singleShot(0, app.quit)', ' QTimer.singleShot(0, window.request_exit)') s = s.replace(' if not account:\n QApplication.quit()', ' if not account:\n self.request_exit()', 1) s = s.replace(' finally:\n QApplication.quit()\n\n def __init__', ' finally:\n self.request_exit()\n\n def __init__', 1) # Keep diagnostic branches completely outside the interactive guard. main = s.index('def main() -> None:') body = s.index(' if "--qt-smoke-test" not in sys.argv:', main) s = s[:body] + MAIN_GUARD + s[body:] session = s.index('def _run_desktop_session(') pos = s.index(' window = MainWindow()\n', session) s = s[:pos] + s[pos:].replace(' window = MainWindow()\n', ' window = MainWindow()\n window_holder[0] = window\n if "--qt-smoke-test" not in sys.argv:\n app.setQuitOnLastWindowClosed(False)\n', 1) s = s.replace('class MainWindow(QMainWindow):', QUIT_FILTER + 'class MainWindow(QMainWindow):', 1) compile(s, str(path), 'exec') tmp = path.with_suffix('.tray.tmp') tmp.write_text(s, encoding='utf-8') os.replace(tmp, path) diffname = 'production-qt.diff' if root == Path('C:/wechat_rpa') else 'source-qt.diff' (ARTIFACT / diffname).write_text(''.join(difflib.unified_diff(old.splitlines(True), s.splitlines(True), fromfile=str(backup), tofile=str(path))), encoding='utf-8') print(root, 'updated')