foxygit / RPA-Remisser Log in
commits tags

/rpa/desktop.py · 11.52 KB

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