"""Desktop-automation för Windows: styr vanliga appar via Windows UI Automation (pywinauto) och gör bild-/koordinatklick (pyautogui) för appar utan tillgänglighets-API. Används av rpa/flow_engine.py för stegtyperna `desktop_*` (se rpa/schema.py). pywinauto/pyautogui importeras på modulnivå men bakom try/except så att flow_engine kan importeras även på icke-Windows – anropas ett desktop-steg där kastas DesktopUnavailable i stället för ImportError. Selector-modell (samma anda som browser-stegen): by: "name" | "auto_id" | "control_type" | "class_name" value: söksträng enligt `by` window: valfri toppfönstertitel (regex). Tom = "aktuellt" fönster (senast startat/anslutet/fokuserat). nth: 0 = första träffen """ from __future__ import annotations import time from pathlib import Path from typing import Callable from . import config try: # Windows-only import pywinauto from pywinauto import Application, Desktop except Exception: # noqa: BLE001 (ImportError, men även t.ex. comtypes-fel) pywinauto = None Application = None Desktop = None try: import pyautogui except Exception: # noqa: BLE001 pyautogui = None DESKTOP_IMAGE_DIR = config.DESKTOP_IMAGE_DIR LogFn = Callable[[str], None] class DesktopUnavailable(RuntimeError): """pywinauto/pyautogui saknas eller kunde inte laddas (icke-Windows?).""" class DesktopError(RuntimeError): """Fönster/kontroll/bild hittades inte, e.dyl.""" def _need_pywinauto() -> None: if pywinauto is None: raise DesktopUnavailable( "pywinauto kunde inte laddas – desktop-steg fungerar bara på Windows. " "Installera med: pip install -r requirements.txt" ) def _need_pyautogui() -> None: if pyautogui is None: raise DesktopUnavailable( "pyautogui kunde inte laddas – installera med: pip install -r requirements.txt" ) _BY_TO_KWARG = { "name": "title", "title": "title", "auto_id": "auto_id", "control_type": "control_type", "class_name": "class_name", } class DesktopSession: def __init__(self, log: LogFn = print): self.log = log self.app = None # senaste pywinauto.Application self.win = None # senaste WindowSpecification ("aktuellt fönster") self.launched: list = [] # Application-objekt som detta flöde startat # -- fönster -------------------------------------------------------------- def _find_window(self, title: str, timeout: float): """WindowSpecification för första synliga toppfönstret vars titel matchar (regex). Tål flera träffar. Kastar DesktopError vid timeout.""" end = time.time() + timeout last: Exception | None = None while time.time() < end: try: matches = Desktop(backend="uia").windows(title_re=title, visible_only=True, enabled_only=False) except Exception as exc: # noqa: BLE001 last = exc matches = [] if matches: return Desktop(backend="uia").window(handle=matches[0].handle) time.sleep(0.4) raise DesktopError(f"Hittade inget synligt fönster med titel som matchar {title!r} ({last}).") def launch( self, path: str, args: str = "", cwd: str | None = None, timeout: float = 20, title: str | None = None ) -> None: _need_pywinauto() if not path: raise DesktopError("desktop_launch kräver 'path'.") cmd = path if not args else f'"{path}" {args}' if " " in path else f"{path} {args}" app = Application(backend="uia").start(cmd, work_dir=cwd, timeout=timeout) self.app = app self.launched.append(app) self.log(f" Startade: {cmd}") if title: self.win = self._find_window(title, timeout) self.log(f" Fönster: {self.win.window_text()!r}") return end = time.time() + timeout win = None while time.time() < end and win is None: try: win = app.top_window() win.wait("exists visible", timeout=1) except Exception: # noqa: BLE001 win = None time.sleep(0.4) if win is None: # Paketerade appar (Win11 Notepad m.fl.) körs via en broker - den # startade processen har inget eget fönster. Bästa gissning: det # nyaste synliga toppfönstret. try: cands = [w for w in Desktop(backend="uia").windows(visible_only=True) if w.window_text()] if cands: win = Desktop(backend="uia").window(handle=cands[0].handle) except Exception: # noqa: BLE001 pass self.win = win if win is None: self.log( " (kunde inte fastställa fönstret automatiskt - ange 'title' på " "desktop_launch, eller använd desktop_connect)" ) else: self.log(f" Fönster: {win.window_text()!r}") def connect(self, title: str, timeout: float = 20) -> None: _need_pywinauto() if not title: raise DesktopError("desktop_connect kräver 'title'.") self.win = self._find_window(title, timeout) try: self.app = Application(backend="uia").connect(handle=self.win.handle, timeout=1) except Exception: # noqa: BLE001 self.app = None self.log(f" Anslöt till fönster: {self.win.window_text()!r}") def wait_for_window(self, title: str, timeout: float = 20) -> None: _need_pywinauto() self._find_window(title, timeout) self.log(f" Fönster finns: {title}") def focus(self, title: str | None = None, timeout: float = 10) -> None: win = self._window(title, timeout) win.set_focus() self.win = win self.log(" Fokuserade fönstret") def _window(self, title: str | None = None, timeout: float = 10): _need_pywinauto() if title: return self._find_window(title, timeout) if self.win is None: raise DesktopError( "Inget aktuellt fönster – kör desktop_launch/desktop_connect först " "eller ange 'window'." ) return self.win # -- kontroller --------------------------------------------------------- def _control(self, by: str, value: str, control_type: str, window: str | None, nth: int): win = self._window(window) kwargs: dict = {} key = _BY_TO_KWARG.get(by, "title") if value: kwargs["title_re" if key == "title" else key] = value if control_type: kwargs["control_type"] = control_type ctrl = win.child_window(**kwargs) if nth: ctrl = win.child_window(**kwargs, found_index=nth) ctrl.wait("exists", timeout=10) return ctrl def click(self, by, value, control_type, window, nth, double=False) -> None: ctrl = self._control(by, value, control_type, window, nth) if double: ctrl.double_click_input() else: ctrl.click_input() self.log(f" {'Dubbelklick' if double else 'Klick'}: {value or control_type}") def set_text(self, by, value, control_type, window, nth, text: str) -> None: ctrl = self._control(by, value, control_type, window, nth) try: ctrl.set_edit_text(text) except Exception: # noqa: BLE001 – inte ett Edit-fält ctrl.set_focus() ctrl.type_keys(_escape_type_keys(text), with_spaces=True, with_newlines=True) self.log(f" Skrev text i: {value or control_type}") def press_key(self, keys: str, window: str | None = None) -> None: _need_pywinauto() if not keys: raise DesktopError("desktop_press_key kräver 'keys'.") win = self._window(window) win.set_focus() win.type_keys(keys, with_spaces=True, set_foreground=True) self.log(f" Tangenter: {keys}") def read_text(self, by, value, control_type, window, nth) -> str: ctrl = self._control(by, value, control_type, window, nth) txt = "" for getter in ("window_text", "get_value", "legacy_properties"): try: val = getattr(ctrl, getter)() if getter == "legacy_properties": val = (val or {}).get("Value", "") if val: txt = val break except Exception: # noqa: BLE001 continue self.log(f" Läste text: {txt[:60]!r}") return txt or "" def screenshot(self, name: str = "desktop", window: str | None = None) -> Path: config.SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) target = config.SCREENSHOT_DIR / f"{name}.png" if window or self.win is not None: win = self._window(window) win.capture_as_image().save(str(target)) else: _need_pyautogui() pyautogui.screenshot(str(target)) self.log(f" Skärmdump: {target.name}") return target # -- bild / koordinat ------------------------------------------------- def _image_path(self, image: str) -> str: p = Path(image) if not p.is_absolute(): p = DESKTOP_IMAGE_DIR / image if not p.exists(): raise DesktopError(f"Bildfilen finns inte: {p}") return str(p) def _locate(self, image: str, confidence: float, timeout: float): _need_pyautogui() path = self._image_path(image) end = time.time() + timeout last = None while time.time() < end: try: pt = pyautogui.locateCenterOnScreen(path, confidence=confidence) except Exception as exc: # noqa: BLE001 – pyscreeze ImageNotFoundException m.fl. last = exc pt = None if pt is not None: return pt time.sleep(0.5) raise DesktopError(f"Hittade inte bilden {image!r} på skärmen inom {timeout}s ({last}).") def wait_for_image(self, image: str, confidence: float = 0.9, timeout: float = 20) -> None: self._locate(image, confidence, timeout) self.log(f" Bild funnen: {image}") def click_image( self, image: str, confidence: float = 0.9, timeout: float = 20, double: bool = False, button: str = "left" ) -> None: pt = self._locate(image, confidence, timeout) pyautogui.click(pt.x, pt.y, clicks=2 if double else 1, button=button) self.log(f" Klick på bild {image} @ ({pt.x},{pt.y})") def click_xy(self, x: int, y: int, double: bool = False, button: str = "left") -> None: _need_pyautogui() pyautogui.click(int(x), int(y), clicks=2 if double else 1, button=button) self.log(f" Klick @ ({x},{y})") # -- städning --------------------------------------------------------- def close(self, kill_launched: bool) -> None: if not kill_launched: if self.launched: self.log("Lämnar startade appar igång (close-mode).") return for app in self.launched: try: app.kill(soft=False) except Exception as exc: # noqa: BLE001 self.log(f" Kunde inte stänga en app: {exc}") self.launched.clear() def _escape_type_keys(text: str) -> str: # type_keys tolkar {}()+^%~ som specialtecken – escapa dem som literaler. out = [] for ch in text: out.append("{" + ch + "}" if ch in "{}()+^%~[]" else ch) return "".join(out)