commits
tags
"""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)