"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。""" from __future__ import annotations import ctypes import ntpath import time from ctypes import wintypes from dataclasses import dataclass from typing import Iterable from util.selectors import WindowSpec class DesktopRpaError(RuntimeError): def __init__(self, code: str, message: str) -> None: super().__init__(f"ERROR:{code} {message}") self.code = code self.message = message @dataclass(frozen=True) class WindowCandidate: hwnd: int pid: int process_name: str class_name: str title: str visible: bool user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 SW_RESTORE = 9 SW_MAXIMIZE = 3 PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 def _process_name(pid: int) -> str: handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) if not handle: return "" try: size = wintypes.DWORD(32768) buffer = ctypes.create_unicode_buffer(size.value) if not kernel32.QueryFullProcessImageNameW( handle, 0, buffer, ctypes.byref(size), ): return "" return ntpath.splitext(ntpath.basename(buffer.value))[0] finally: kernel32.CloseHandle(handle) def enumerate_top_windows() -> list[WindowCandidate]: found: list[WindowCandidate] = [] @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) def callback(hwnd, _lparam): pid = wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) cls = ctypes.create_unicode_buffer(256) user32.GetClassNameW(hwnd, cls, 256) length = user32.GetWindowTextLengthW(hwnd) title = ctypes.create_unicode_buffer(length + 1) user32.GetWindowTextW(hwnd, title, length + 1) found.append( WindowCandidate( hwnd=int(hwnd), pid=int(pid.value), process_name=_process_name(int(pid.value)), class_name=cls.value, title=title.value, visible=bool(user32.IsWindowVisible(hwnd)), ) ) return True user32.EnumWindows(callback, 0) return found def resolve_unique_window( spec: WindowSpec, *, candidates: Iterable[WindowCandidate] | None = None, ) -> WindowCandidate: pool = list(candidates) if candidates is not None else enumerate_top_windows() matched = [ item for item in pool if item.visible and item.process_name.casefold() == spec.process_name.casefold() and item.class_name == spec.class_name and (spec.title is None or item.title == spec.title) ] if not matched: raise DesktopRpaError("WINDOW_NOT_FOUND", f"{spec.logical_name} 未找到") if len(matched) != 1: raise DesktopRpaError( "ELEMENT_AMBIGUOUS", f"{spec.logical_name} 匹配 {len(matched)} 个窗口,请按 PID/账号补充作用域", ) return matched[0] def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None: """激活窗口。 maximize=True:主窗进入依赖完整侧栏的流程时使用。 maximize=False:只前置;**禁止**对已最大化窗口无条件 SW_RESTORE。 """ hwnd = window.hwnd iconic = bool(user32.IsIconic(hwnd)) zoomed = bool(user32.IsZoomed(hwnd)) if maximize: if iconic: user32.ShowWindow(hwnd, SW_RESTORE) if not zoomed: user32.ShowWindow(hwnd, SW_MAXIMIZE) else: if iconic: user32.ShowWindow(hwnd, SW_RESTORE) if not user32.SetForegroundWindow(hwnd): raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={hwnd}") time.sleep(0.35 if maximize else 0.2)