"""UI 状态机 基于微信窗口数量和名称判断当前 UI 状态,提供状态恢复能力。 微信 v4.1.9 窗口模型: - 1 个窗口("微信"):主聊天列表 - 2 个窗口("微信" + 聊天名):会话已打开 - 3+ 个窗口:可能有图片预览等额外窗口 """ import logging import time from enum import Enum from Cocoa import NSRunningApplication from .ax_bridge import AXBridge from .wechat_ui import WeChatUI logger = logging.getLogger("wechat_clicker.state_machine") class UIState(Enum): UNKNOWN = "unknown" MAIN_CHAT_LIST = "main_chat_list" CONVERSATION_OPEN = "conversation_open" MEDIA_PREVIEW = "media_preview" WECHAT_NOT_RUNNING = "wechat_not_running" WECHAT_MINIMIZED = "wechat_minimized" class StateMachine: """微信 UI 状态机""" def __init__(self, ax: AXBridge, wechat_ui: WeChatUI): self.ax = ax self.ui = wechat_ui self._current_state = UIState.UNKNOWN self._conversation_name = None # 当前打开的会话名称 @property def current_state(self) -> UIState: return self._current_state @property def conversation_name(self) -> str: return self._conversation_name or "" # ---------------------------------------------------------------- # 状态检测 # ---------------------------------------------------------------- def detect_state(self) -> UIState: """检测当前微信 UI 状态。""" windows = self.ui.get_all_windows() if not windows: if self.ax.is_app_running(self.ui.bundle_id): self._current_state = UIState.WECHAT_MINIMIZED self._conversation_name = None logger.debug("微信在运行但无可见窗口(可能已最小化或隐藏)") else: self._current_state = UIState.WECHAT_NOT_RUNNING self._conversation_name = None logger.debug("微信未运行") return self._current_state # 检查主窗口是否最小化(AXWindows 会包含最小化的窗口) main_window = None all_minimized = True other_windows = [] for win in windows: title = self.ax.get_title(win) is_mini = self.ax.is_window_minimized(win) if title == "微信": main_window = win elif title: other_windows.append((win, title)) if not is_mini: all_minimized = False if all_minimized: self._current_state = UIState.WECHAT_MINIMIZED self._conversation_name = None logger.debug("微信所有窗口均已最小化") return self._current_state if main_window is None: self._current_state = UIState.UNKNOWN logger.debug("未找到微信主窗口") return self._current_state window_count = len(windows) if window_count == 1: self._current_state = UIState.MAIN_CHAT_LIST self._conversation_name = None elif window_count == 2 and len(other_windows) == 1: self._current_state = UIState.CONVERSATION_OPEN self._conversation_name = other_windows[0][1] elif window_count >= 3: self._current_state = UIState.MEDIA_PREVIEW if other_windows: self._conversation_name = other_windows[0][1] else: self._current_state = UIState.UNKNOWN other_titles = [t for _, t in other_windows] logger.debug( f"状态检测: {self._current_state.value}, " f"窗口数={window_count}, 会话={self._conversation_name}, " f"其他窗口={other_titles}" ) return self._current_state # ---------------------------------------------------------------- # 状态恢复 # ---------------------------------------------------------------- def recover_to_chat_list(self, max_retries: int = 3) -> bool: """恢复到主聊天列表状态。关闭所有会话和预览窗口。 Returns: True 如果成功恢复到聊天列表。 """ for attempt in range(max_retries): state = self.detect_state() if state == UIState.MAIN_CHAT_LIST: return True if state == UIState.WECHAT_NOT_RUNNING: logger.error("微信未运行,无法恢复") return False if state == UIState.WECHAT_MINIMIZED: logger.info("微信窗口已最小化,正在恢复...") if self.ui.ensure_wechat_visible(): time.sleep(0.5) continue else: logger.error("微信窗口恢复失败") return False if state == UIState.MEDIA_PREVIEW: # 先关闭预览(Escape) logger.info("关闭媒体预览...") self.ax.send_escape_key() time.sleep(0.5) if state in (UIState.CONVERSATION_OPEN, UIState.MEDIA_PREVIEW): # 关闭会话窗口 self._close_all_conversations() time.sleep(0.5) if state == UIState.UNKNOWN: # 尝试激活微信并点击侧边栏聊天按钮 self.ui.ensure_wechat_frontmost() time.sleep(0.5) self.ui.click_sidebar_chat_tab() time.sleep(0.5) logger.debug(f"恢复尝试 {attempt + 1}/{max_retries}") # 最终检查 final_state = self.detect_state() if final_state == UIState.MAIN_CHAT_LIST: return True logger.error(f"恢复失败,当前状态: {final_state.value}") return False def _close_all_conversations(self): """关闭所有会话窗口。""" conv_windows = self.ui.get_conversation_windows() for win in conv_windows: title = self.ax.get_title(win) close_btn = self.ui.get_close_button(win) if close_btn: logger.debug(f"关闭会话窗口: {title}") self.ax.press(close_btn) time.sleep(0.3) else: # 后备方案:Cmd+W logger.debug(f"使用 Cmd+W 关闭窗口: {title}") self.ax.send_cmd_w() time.sleep(0.3) def close_current_conversation(self) -> bool: """关闭当前打开的会话窗口。""" conv_window = self.ui.get_conversation_window() if conv_window is None: return True # 没有会话窗口,视为成功 close_btn = self.ui.get_close_button(conv_window) if close_btn: self.ax.press(close_btn) else: self.ax.send_cmd_w() time.sleep(0.5) # 验证 state = self.detect_state() return state == UIState.MAIN_CHAT_LIST def dismiss_preview(self) -> bool: """关闭媒体预览。""" state = self.detect_state() if state != UIState.MEDIA_PREVIEW: return True self.ax.send_escape_key() time.sleep(0.5) state = self.detect_state() if state == UIState.MEDIA_PREVIEW: self.ax.send_escape_key() time.sleep(0.5) state = self.detect_state() return state != UIState.MEDIA_PREVIEW # ---------------------------------------------------------------- # Preview.app 处理 # ---------------------------------------------------------------- def close_preview_app(self) -> bool: """关闭 macOS Preview.app(预览)窗口。""" preview_bundle = "com.apple.Preview" apps = NSRunningApplication.runningApplicationsWithBundleIdentifier_(preview_bundle) if not apps or len(apps) == 0: return True app = apps[0] if app.isTerminated(): return True logger.debug("检测到 Preview.app 正在运行,发送 Cmd+W 关闭窗口") app.activateWithOptions_(0) time.sleep(0.5) self.ax.send_cmd_w() time.sleep(0.5) self.ui.ensure_wechat_frontmost() time.sleep(0.3) return True def is_preview_app_running(self) -> bool: """检查 Preview.app 是否在运行。""" preview_bundle = "com.apple.Preview" apps = NSRunningApplication.runningApplicationsWithBundleIdentifier_(preview_bundle) if not apps or len(apps) == 0: return False return not apps[0].isTerminated()