AutoMonkey 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- automonkey/__init__.py +37 -0
- automonkey/ai/__init__.py +54 -0
- automonkey/ai/__main__.py +76 -0
- automonkey/ai/mcp_launch.py +62 -0
- automonkey/ai/mcp_server.py +114 -0
- automonkey/ai/ocr.py +149 -0
- automonkey/ai/screen.py +202 -0
- automonkey/ai/script.py +190 -0
- automonkey/ai/windows.py +185 -0
- automonkey/app_funcs.py +201 -0
- automonkey/atspi_funcs.py +1093 -0
- automonkey/automonkey.py +300 -0
- automonkey/constants.py +120 -0
- automonkey/exceptions.py +16 -0
- automonkey/img_funcs.py +301 -0
- automonkey/key_funcs.py +279 -0
- automonkey/mouse_funcs.py +37 -0
- automonkey/mouse_tracker.py +133 -0
- automonkey/pywinauto_funcs.py +343 -0
- automonkey/utils.py +169 -0
- automonkey-0.4.1.dist-info/METADATA +517 -0
- automonkey-0.4.1.dist-info/RECORD +27 -0
- automonkey-0.4.1.dist-info/WHEEL +5 -0
- automonkey-0.4.1.dist-info/entry_points.txt +3 -0
- automonkey-0.4.1.dist-info/licenses/AUTHORS.rst +13 -0
- automonkey-0.4.1.dist-info/licenses/LICENSE +33 -0
- automonkey-0.4.1.dist-info/top_level.txt +1 -0
automonkey/ai/screen.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Screen capture, coordinate mapping and change detection for AI agents.
|
|
2
|
+
|
|
3
|
+
Three coordinate spaces exist:
|
|
4
|
+
* capture pixels - what the screenshot backend returns (physical pixels)
|
|
5
|
+
* input pixels - what pyautogui uses for mouse moves
|
|
6
|
+
* image pixels - the downscaled screenshot the AI actually looks at
|
|
7
|
+
|
|
8
|
+
The AI only ever deals with *image pixels*. Everything here converts for it.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pyautogui
|
|
21
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
22
|
+
|
|
23
|
+
DEFAULT_MAX_SIDE = int(os.environ.get("AUTOMONKEY_AI_MAX_SIDE", "1280"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def session_type() -> str:
|
|
27
|
+
"""'wayland', 'x11', 'win32' or 'darwin'."""
|
|
28
|
+
if sys.platform.startswith("linux"):
|
|
29
|
+
return "wayland" if os.environ.get("WAYLAND_DISPLAY") else "x11"
|
|
30
|
+
return sys.platform
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def grab() -> Image.Image:
|
|
34
|
+
"""Full-resolution RGB screenshot. Uses grim on Wayland, pyautogui elsewhere."""
|
|
35
|
+
if session_type() == "wayland" and shutil.which("grim"):
|
|
36
|
+
# Capturing full size and resizing in PIL is ~3x faster than `grim -s`.
|
|
37
|
+
out = subprocess.run(["grim", "-t", "ppm", "-"], capture_output=True, check=True).stdout
|
|
38
|
+
return Image.open(io.BytesIO(out)).convert("RGB")
|
|
39
|
+
return pyautogui.screenshot().convert("RGB")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class View:
|
|
43
|
+
"""Maps between image pixels (what the AI sees) and input pixels (pyautogui)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, capture_size: tuple[int, int], max_side: int = DEFAULT_MAX_SIDE):
|
|
46
|
+
self.capture_w, self.capture_h = capture_size
|
|
47
|
+
self.input_w, self.input_h = pyautogui.size()
|
|
48
|
+
self.scale = min(1.0, max_side / max(self.capture_w, self.capture_h))
|
|
49
|
+
self.image_w = round(self.capture_w * self.scale)
|
|
50
|
+
self.image_h = round(self.capture_h * self.scale)
|
|
51
|
+
|
|
52
|
+
def to_input(self, x: float, y: float) -> tuple[int, int]:
|
|
53
|
+
return (round(x * self.input_w / self.image_w), round(y * self.input_h / self.image_h))
|
|
54
|
+
|
|
55
|
+
def to_image(self, x: float, y: float) -> tuple[int, int]:
|
|
56
|
+
return (round(x * self.image_w / self.input_w), round(y * self.image_h / self.input_h))
|
|
57
|
+
|
|
58
|
+
def capture_to_image(self, x: float, y: float) -> tuple[int, int]:
|
|
59
|
+
return (round(x * self.scale), round(y * self.scale))
|
|
60
|
+
|
|
61
|
+
def describe(self) -> str:
|
|
62
|
+
return f"image {self.image_w}x{self.image_h} (screen {self.input_w}x{self.input_h})"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_view: View | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def view() -> View:
|
|
69
|
+
"""The current coordinate mapping (created from a fresh capture if needed)."""
|
|
70
|
+
global _view
|
|
71
|
+
if _view is None:
|
|
72
|
+
_view = View(grab().size)
|
|
73
|
+
return _view
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _font(size: int):
|
|
77
|
+
try:
|
|
78
|
+
return ImageFont.load_default(size=size)
|
|
79
|
+
except TypeError: # Pillow < 10.1
|
|
80
|
+
return ImageFont.load_default()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def draw_grid(img: Image.Image, step: int, origin: tuple[int, int] = (0, 0), unit: float = 1.0) -> Image.Image:
|
|
84
|
+
"""Overlay a labelled grid. Labels are in image coordinates of the full view,
|
|
85
|
+
so `origin` and `unit` let zoomed crops show the same numbers as the full shot."""
|
|
86
|
+
img = img.convert("RGBA")
|
|
87
|
+
layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
|
88
|
+
d = ImageDraw.Draw(layer)
|
|
89
|
+
font = _font(11)
|
|
90
|
+
ox, oy = origin
|
|
91
|
+
first_x = (-ox) % step
|
|
92
|
+
for vx in range(ox + first_x, ox + int(img.width / unit) + 1, step):
|
|
93
|
+
px = round((vx - ox) * unit)
|
|
94
|
+
d.line([(px, 0), (px, img.height)], fill=(255, 0, 255, 90), width=1)
|
|
95
|
+
d.text((px + 2, 2), str(vx), fill=(255, 0, 255, 255), font=font, stroke_width=2, stroke_fill=(0, 0, 0, 255))
|
|
96
|
+
first_y = (-oy) % step
|
|
97
|
+
for vy in range(oy + first_y, oy + int(img.height / unit) + 1, step):
|
|
98
|
+
py = round((vy - oy) * unit)
|
|
99
|
+
d.line([(0, py), (img.width, py)], fill=(255, 0, 255, 90), width=1)
|
|
100
|
+
d.text((2, py + 2), str(vy), fill=(255, 0, 255, 255), font=font, stroke_width=2, stroke_fill=(0, 0, 0, 255))
|
|
101
|
+
return Image.alpha_composite(img, layer).convert("RGB")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def draw_mouse(img: Image.Image, at: tuple[int, int], radius: int = 7) -> Image.Image:
|
|
105
|
+
"""Mark the mouse position with a red ring (screenshots don't include the cursor)."""
|
|
106
|
+
d = ImageDraw.Draw(img)
|
|
107
|
+
x, y = at
|
|
108
|
+
d.ellipse([x - radius, y - radius, x + radius, y + radius], outline=(255, 0, 0), width=2)
|
|
109
|
+
return img
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def shot(grid: int = 0, max_side: int = DEFAULT_MAX_SIDE, show_mouse: bool = True,
|
|
113
|
+
full: Image.Image | None = None) -> Image.Image:
|
|
114
|
+
"""Screenshot scaled for the AI. All coordinates the AI reads off it are valid input."""
|
|
115
|
+
global _view
|
|
116
|
+
full = full or grab()
|
|
117
|
+
_view = View(full.size, max_side)
|
|
118
|
+
img = full.resize((_view.image_w, _view.image_h), Image.LANCZOS) if _view.scale < 1 else full
|
|
119
|
+
if show_mouse:
|
|
120
|
+
img = draw_mouse(img, _view.to_image(*pyautogui.position()))
|
|
121
|
+
if grid:
|
|
122
|
+
img = draw_grid(img, grid)
|
|
123
|
+
return img
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def zoom(x: int, y: int, w: int, h: int, grid: int = 0) -> Image.Image:
|
|
127
|
+
"""Full-resolution crop of an image-coordinate region, enlarged for reading
|
|
128
|
+
small text. Grid labels (if any) are in the same image coordinates."""
|
|
129
|
+
v = view()
|
|
130
|
+
full = grab()
|
|
131
|
+
s = 1 / v.scale
|
|
132
|
+
box = (round(x * s), round(y * s), round((x + w) * s), round((y + h) * s))
|
|
133
|
+
crop = full.crop(box)
|
|
134
|
+
factor = max(1.0, min(4.0, 800 / max(crop.width, crop.height, 1)))
|
|
135
|
+
crop = crop.resize((round(crop.width * factor), round(crop.height * factor)), Image.LANCZOS)
|
|
136
|
+
if grid:
|
|
137
|
+
crop = draw_grid(crop, grid, origin=(x, y), unit=crop.width / max(w, 1))
|
|
138
|
+
return crop
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def to_png(img: Image.Image) -> bytes:
|
|
142
|
+
buf = io.BytesIO()
|
|
143
|
+
img.save(buf, format="PNG", optimize=False, compress_level=1)
|
|
144
|
+
return buf.getvalue()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --- change detection -------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _small(img: Image.Image) -> np.ndarray:
|
|
150
|
+
return np.asarray(img.resize((img.width // 8, img.height // 8), Image.NEAREST).convert("L"), dtype=np.int16)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def changed_fraction(a: np.ndarray, b: np.ndarray, threshold: int = 24) -> float:
|
|
154
|
+
if a.shape != b.shape:
|
|
155
|
+
return 1.0
|
|
156
|
+
return float((np.abs(a - b) > threshold).mean())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# A blinking text caret or a spinner changes ~0.05% of the screen; ignore that.
|
|
160
|
+
NOISE = 0.002
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def wait_stable(stable_for: float = 1.0, timeout: float = 15.0, poll: float = 0.15) -> tuple[bool, float]:
|
|
164
|
+
"""Wait until the screen stops changing. Returns (stable?, seconds waited)."""
|
|
165
|
+
start = time.monotonic()
|
|
166
|
+
last = _small(grab())
|
|
167
|
+
same_since = time.monotonic()
|
|
168
|
+
while time.monotonic() - start < timeout:
|
|
169
|
+
time.sleep(poll)
|
|
170
|
+
cur = _small(grab())
|
|
171
|
+
if changed_fraction(last, cur) > NOISE:
|
|
172
|
+
last, same_since = cur, time.monotonic()
|
|
173
|
+
elif time.monotonic() - same_since >= stable_for:
|
|
174
|
+
return True, time.monotonic() - start
|
|
175
|
+
return False, time.monotonic() - start
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def wait_change(baseline: Image.Image | None = None, timeout: float = 15.0, poll: float = 0.15) -> tuple[bool, float]:
|
|
179
|
+
"""Wait until the screen differs from `baseline` (default: now)."""
|
|
180
|
+
start = time.monotonic()
|
|
181
|
+
base = _small(baseline or grab())
|
|
182
|
+
while time.monotonic() - start < timeout:
|
|
183
|
+
time.sleep(poll)
|
|
184
|
+
if changed_fraction(base, _small(grab())) > NOISE:
|
|
185
|
+
return True, time.monotonic() - start
|
|
186
|
+
return False, time.monotonic() - start
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def diff_summary(before: Image.Image, after: Image.Image) -> str:
|
|
190
|
+
"""Human/AI readable description of what changed, in image coordinates."""
|
|
191
|
+
a, b = _small(before), _small(after)
|
|
192
|
+
if a.shape != b.shape:
|
|
193
|
+
return "screen size changed"
|
|
194
|
+
mask = np.abs(a - b) > 24
|
|
195
|
+
frac = float(mask.mean())
|
|
196
|
+
if frac <= NOISE:
|
|
197
|
+
return "screen did NOT change"
|
|
198
|
+
ys, xs = np.nonzero(mask)
|
|
199
|
+
v = view()
|
|
200
|
+
x0, y0 = v.capture_to_image(xs.min() * 8, ys.min() * 8)
|
|
201
|
+
x1, y1 = v.capture_to_image((xs.max() + 1) * 8, (ys.max() + 1) * 8)
|
|
202
|
+
return f"screen changed: {frac:.0%} of pixels, region x={x0}..{x1} y={y0}..{y1}"
|
automonkey/ai/script.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""A tiny action language so an AI can do many steps in ONE tool call.
|
|
2
|
+
|
|
3
|
+
One command per line (or separated by ';'; write '\\;' for a literal ';').
|
|
4
|
+
Coordinates are image pixels of the latest screenshot.
|
|
5
|
+
|
|
6
|
+
click X Y [left|right|middle] [N] dclick X Y rclick X Y move X Y
|
|
7
|
+
drag X1 Y1 X2 Y2 scroll N [X Y] (N>0 up, N<0 down)
|
|
8
|
+
type TEXT... key K [K...] hotkey ctrl+shift+t
|
|
9
|
+
wait SECONDS stable [TIMEOUT] change [TIMEOUT]
|
|
10
|
+
launch COMMAND... focus WINDOW maximize [WINDOW] close WINDOW
|
|
11
|
+
url ADDRESS (focus address bar, type, Enter, wait for load)
|
|
12
|
+
click_text TEXT... wait_text TEXT... (OCR, needs tesseract)
|
|
13
|
+
click_image PATH (template match)
|
|
14
|
+
press [BUTTON] release [BUTTON]
|
|
15
|
+
path X1 Y1 X2 Y2 ... (freehand: press, glide through every point, release)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
import pyautogui
|
|
23
|
+
|
|
24
|
+
from . import ocr, screen, windows
|
|
25
|
+
from ..key_funcs import hotkey, press, write
|
|
26
|
+
|
|
27
|
+
pyautogui.PAUSE = 0.03 # default 0.1s per call adds up quickly
|
|
28
|
+
|
|
29
|
+
INPUT_COMMANDS = {"click", "dclick", "rclick", "move", "drag", "scroll", "type", "key", "hotkey",
|
|
30
|
+
"url", "click_text", "click_image", "press", "release", "path"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StepError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def split_script(script: str) -> list[str]:
|
|
38
|
+
lines = []
|
|
39
|
+
for raw in script.splitlines():
|
|
40
|
+
for part in re.split(r"(?<!\\);", raw):
|
|
41
|
+
part = part.replace("\\;", ";").strip()
|
|
42
|
+
if part and not part.startswith("#"):
|
|
43
|
+
lines.append(part)
|
|
44
|
+
return lines
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _xy(args: list[str], i: int = 0) -> tuple[int, int]:
|
|
48
|
+
try:
|
|
49
|
+
return screen.view().to_input(float(args[i]), float(args[i + 1]))
|
|
50
|
+
except (IndexError, ValueError):
|
|
51
|
+
raise StepError("expected X Y coordinates")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _keys(spec: str) -> list[str]:
|
|
55
|
+
return [k.strip().lower() for k in re.split(r"[+\s]+", spec) if k.strip()]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _click_text(text: str):
|
|
59
|
+
hits = ocr.find_text(text)
|
|
60
|
+
if not hits:
|
|
61
|
+
raise StepError(f"text {text!r} not found on screen")
|
|
62
|
+
h = hits[0]
|
|
63
|
+
pyautogui.click(*screen.view().to_input(h["cx"], h["cy"]))
|
|
64
|
+
return f"clicked {h['text']!r} at {h['cx']},{h['cy']}" + (f" ({len(hits)} matches)" if len(hits) > 1 else "")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_step(line: str) -> str:
|
|
68
|
+
cmd, _, rest = line.partition(" ")
|
|
69
|
+
cmd = cmd.lower()
|
|
70
|
+
args = rest.split()
|
|
71
|
+
if cmd == "click":
|
|
72
|
+
x, y = _xy(args)
|
|
73
|
+
button = args[2] if len(args) > 2 else "left"
|
|
74
|
+
clicks = int(args[3]) if len(args) > 3 else 1
|
|
75
|
+
pyautogui.click(x, y, clicks=clicks, interval=0.05, button=button)
|
|
76
|
+
elif cmd == "dclick":
|
|
77
|
+
pyautogui.doubleClick(*_xy(args))
|
|
78
|
+
elif cmd == "rclick":
|
|
79
|
+
pyautogui.rightClick(*_xy(args))
|
|
80
|
+
elif cmd == "move":
|
|
81
|
+
pyautogui.moveTo(*_xy(args))
|
|
82
|
+
elif cmd == "drag":
|
|
83
|
+
x1, y1 = _xy(args)
|
|
84
|
+
x2, y2 = _xy(args, 2)
|
|
85
|
+
pyautogui.moveTo(x1, y1)
|
|
86
|
+
pyautogui.dragTo(x2, y2, duration=0.4, button="left")
|
|
87
|
+
elif cmd == "press":
|
|
88
|
+
pyautogui.mouseDown(button=args[0] if args else "left")
|
|
89
|
+
elif cmd == "release":
|
|
90
|
+
pyautogui.mouseUp(button=args[0] if args else "left")
|
|
91
|
+
elif cmd == "path":
|
|
92
|
+
if len(args) < 4 or len(args) % 2:
|
|
93
|
+
raise StepError("path needs an even number of X Y values (at least two points)")
|
|
94
|
+
pts = [_xy(args, i) for i in range(0, len(args), 2)]
|
|
95
|
+
pyautogui.moveTo(*pts[0])
|
|
96
|
+
pyautogui.mouseDown()
|
|
97
|
+
try:
|
|
98
|
+
for p in pts[1:]:
|
|
99
|
+
pyautogui.moveTo(*p, duration=0.02)
|
|
100
|
+
finally:
|
|
101
|
+
pyautogui.mouseUp()
|
|
102
|
+
return f"drew through {len(pts)} points"
|
|
103
|
+
elif cmd == "scroll":
|
|
104
|
+
if len(args) >= 3:
|
|
105
|
+
pyautogui.moveTo(*_xy(args, 1))
|
|
106
|
+
pyautogui.scroll(int(args[0]))
|
|
107
|
+
elif cmd == "type":
|
|
108
|
+
write(rest)
|
|
109
|
+
elif cmd == "key":
|
|
110
|
+
press(args)
|
|
111
|
+
elif cmd == "hotkey":
|
|
112
|
+
hotkey(*_keys(rest))
|
|
113
|
+
elif cmd == "wait":
|
|
114
|
+
time.sleep(float(args[0]) if args else 1.0)
|
|
115
|
+
elif cmd == "stable":
|
|
116
|
+
ok, t = screen.wait_stable(timeout=float(args[0]) if args else 15)
|
|
117
|
+
return f"stable after {t:.1f}s" if ok else f"still changing after {t:.1f}s"
|
|
118
|
+
elif cmd == "change":
|
|
119
|
+
ok, t = screen.wait_change(timeout=float(args[0]) if args else 15)
|
|
120
|
+
return f"changed after {t:.1f}s" if ok else f"no change within {t:.1f}s"
|
|
121
|
+
elif cmd == "url":
|
|
122
|
+
hotkey("ctrl", "l")
|
|
123
|
+
time.sleep(0.15)
|
|
124
|
+
write(rest)
|
|
125
|
+
time.sleep(0.3) # let the address bar's suggestion list catch up before Enter
|
|
126
|
+
before = screen.grab() # after typing, so the typed text alone doesn't count as "loaded"
|
|
127
|
+
press("enter")
|
|
128
|
+
changed, _ = screen.wait_change(before, timeout=30)
|
|
129
|
+
if not changed:
|
|
130
|
+
return "no visible change 30s after Enter (still loading, or not navigated)"
|
|
131
|
+
ok, t = screen.wait_stable(timeout=20)
|
|
132
|
+
return f"loaded{'' if ok else ' (still changing)'}"
|
|
133
|
+
elif cmd == "launch":
|
|
134
|
+
w = windows.launch(rest)
|
|
135
|
+
windows.focus(w["id"])
|
|
136
|
+
screen.wait_stable(timeout=20)
|
|
137
|
+
return f"window {w['id']} {w['title']!r}"
|
|
138
|
+
elif cmd == "focus":
|
|
139
|
+
w = windows.focus(rest)
|
|
140
|
+
return f"focused {w['title']!r}"
|
|
141
|
+
elif cmd == "maximize":
|
|
142
|
+
w = windows.maximize(rest or None)
|
|
143
|
+
screen.wait_stable(stable_for=0.5, timeout=5)
|
|
144
|
+
return f"maximized {w['title']!r}"
|
|
145
|
+
elif cmd == "close":
|
|
146
|
+
w = windows.close(rest)
|
|
147
|
+
return f"closed {w['title']!r}"
|
|
148
|
+
elif cmd == "click_text":
|
|
149
|
+
return _click_text(rest)
|
|
150
|
+
elif cmd == "wait_text":
|
|
151
|
+
deadline = time.monotonic() + 20
|
|
152
|
+
while time.monotonic() < deadline:
|
|
153
|
+
if ocr.find_text(rest):
|
|
154
|
+
return "text appeared"
|
|
155
|
+
time.sleep(0.5)
|
|
156
|
+
raise StepError(f"text {rest!r} did not appear within 20s")
|
|
157
|
+
elif cmd == "click_image":
|
|
158
|
+
box = pyautogui.locate(rest, screen.grab(), grayscale=True)
|
|
159
|
+
if not box:
|
|
160
|
+
raise StepError(f"image {rest!r} not found on screen")
|
|
161
|
+
cx, cy = screen.view().capture_to_image(box.left + box.width / 2, box.top + box.height / 2)
|
|
162
|
+
pyautogui.click(*screen.view().to_input(cx, cy))
|
|
163
|
+
return f"clicked image at {cx},{cy}"
|
|
164
|
+
else:
|
|
165
|
+
raise StepError(f"unknown command {cmd!r}")
|
|
166
|
+
return "ok"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run(script: str, settle: bool = True) -> tuple[list[str], bool]:
|
|
170
|
+
"""Run every step, stopping at the first failure. Returns (log, success).
|
|
171
|
+
With `settle`, waits for the screen to stop changing after input steps."""
|
|
172
|
+
log, steps = [], split_script(script)
|
|
173
|
+
for n, line in enumerate(steps, 1):
|
|
174
|
+
t0 = time.monotonic()
|
|
175
|
+
try:
|
|
176
|
+
result = run_step(line)
|
|
177
|
+
log.append(f"{n}. {line} -> {result} ({time.monotonic() - t0:.1f}s)")
|
|
178
|
+
except pyautogui.FailSafeException:
|
|
179
|
+
log.append(f"{n}. {line} -> ABORTED: mouse in a screen corner (pyautogui fail-safe)")
|
|
180
|
+
return log, False
|
|
181
|
+
except Exception as e: # report and stop; the AI decides what to do next
|
|
182
|
+
log.append(f"{n}. {line} -> FAILED: {e}")
|
|
183
|
+
if n < len(steps):
|
|
184
|
+
log.append(f" skipped {len(steps) - n} remaining step(s)")
|
|
185
|
+
return log, False
|
|
186
|
+
last = steps[-1].split()[0].lower() if steps else ""
|
|
187
|
+
if settle and last in INPUT_COMMANDS:
|
|
188
|
+
ok, t = screen.wait_stable(stable_for=0.6, timeout=10)
|
|
189
|
+
log.append(f"settled in {t:.1f}s" if ok else f"screen still changing after {t:.1f}s")
|
|
190
|
+
return log, True
|
automonkey/ai/windows.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Launching apps and finding / focusing windows.
|
|
2
|
+
|
|
3
|
+
Linux uses xdotool + wmctrl. On Wayland, pyautogui input only reaches X11
|
|
4
|
+
(XWayland) windows, so `launch` forces apps onto X11 automatically.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from .screen import session_type
|
|
16
|
+
|
|
17
|
+
# Env vars that make common toolkits use X11 instead of native Wayland.
|
|
18
|
+
X11_ENV = {
|
|
19
|
+
"MOZ_ENABLE_WAYLAND": "0", # Firefox / Thunderbird
|
|
20
|
+
"GDK_BACKEND": "x11", # GTK
|
|
21
|
+
"QT_QPA_PLATFORM": "xcb", # Qt
|
|
22
|
+
"SDL_VIDEODRIVER": "x11", # SDL
|
|
23
|
+
"ELECTRON_OZONE_PLATFORM_HINT": "x11",
|
|
24
|
+
}
|
|
25
|
+
FIREFOX_LIKE = ("firefox", "firefox-esr", "thunderbird")
|
|
26
|
+
CHROMIUM_LIKE = ("chromium", "chromium-browser", "google-chrome", "chrome", "code", "brave-browser")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run(*args: str) -> str:
|
|
30
|
+
return subprocess.run(args, capture_output=True, text=True).stdout
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require_linux_tools():
|
|
34
|
+
missing = [t for t in ("xdotool", "wmctrl") if not shutil.which(t)]
|
|
35
|
+
if missing:
|
|
36
|
+
raise RuntimeError(f"missing tools: {', '.join(missing)} (sudo apt install {' '.join(missing)})")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def list_windows() -> list[dict]:
|
|
40
|
+
"""Visible top-level windows: id, title, wm_class, x, y, w, h, active."""
|
|
41
|
+
if sys.platform == "win32":
|
|
42
|
+
import pygetwindow as gw
|
|
43
|
+
active = gw.getActiveWindow()
|
|
44
|
+
return [dict(id=w._hWnd, title=w.title, wm_class="", x=w.left, y=w.top, w=w.width, h=w.height,
|
|
45
|
+
active=active is not None and w._hWnd == active._hWnd)
|
|
46
|
+
for w in gw.getAllWindows() if w.title and w.visible]
|
|
47
|
+
_require_linux_tools()
|
|
48
|
+
active = _run("xdotool", "getactivewindow").strip()
|
|
49
|
+
wins = []
|
|
50
|
+
for line in _run("wmctrl", "-lGx").splitlines():
|
|
51
|
+
parts = line.split(None, 8)
|
|
52
|
+
if len(parts) < 8:
|
|
53
|
+
continue
|
|
54
|
+
wid, _desk, x, y, w, h, wm_class = parts[:7]
|
|
55
|
+
title = parts[8] if len(parts) > 8 else ""
|
|
56
|
+
wins.append(dict(id=int(wid, 16), title=title, wm_class=wm_class, x=int(x), y=int(y),
|
|
57
|
+
w=int(w), h=int(h), active=active.isdigit() and int(active) == int(wid, 16)))
|
|
58
|
+
return wins
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_window(query: str | int) -> dict:
|
|
62
|
+
"""Window whose id equals `query`, or whose title / class contains it (case-insensitive)."""
|
|
63
|
+
wins = list_windows()
|
|
64
|
+
if isinstance(query, int) or str(query).isdigit():
|
|
65
|
+
for w in wins:
|
|
66
|
+
if w["id"] == int(query):
|
|
67
|
+
return w
|
|
68
|
+
q = str(query).lower()
|
|
69
|
+
matches = [w for w in wins if q in w["title"].lower() or q in w["wm_class"].lower()]
|
|
70
|
+
if not matches:
|
|
71
|
+
titles = "; ".join(w["title"] for w in wins) or "none"
|
|
72
|
+
raise LookupError(f"no window matching {query!r}. Open windows: {titles}")
|
|
73
|
+
return next((w for w in matches if w["active"]), matches[-1])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def focus(query: str | int) -> dict:
|
|
77
|
+
w = find_window(query)
|
|
78
|
+
if sys.platform == "win32":
|
|
79
|
+
import pygetwindow as gw
|
|
80
|
+
gw.Win32Window(w["id"]).activate()
|
|
81
|
+
else:
|
|
82
|
+
subprocess.run(["xdotool", "windowactivate", "--sync", str(w["id"])], capture_output=True, timeout=5)
|
|
83
|
+
time.sleep(0.2)
|
|
84
|
+
return w
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def maximize(query: str | int | None = None) -> dict:
|
|
88
|
+
w = find_window(query) if query is not None else active_window()
|
|
89
|
+
if sys.platform == "win32":
|
|
90
|
+
import pygetwindow as gw
|
|
91
|
+
gw.Win32Window(w["id"]).maximize()
|
|
92
|
+
else:
|
|
93
|
+
subprocess.run(["wmctrl", "-i", "-r", hex(w["id"]), "-b", "add,maximized_vert,maximized_horz"],
|
|
94
|
+
capture_output=True)
|
|
95
|
+
time.sleep(0.5)
|
|
96
|
+
return find_window(w["id"])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def close(query: str | int) -> dict:
|
|
100
|
+
w = find_window(query)
|
|
101
|
+
if sys.platform == "win32":
|
|
102
|
+
import pygetwindow as gw
|
|
103
|
+
gw.Win32Window(w["id"]).close()
|
|
104
|
+
else:
|
|
105
|
+
subprocess.run(["wmctrl", "-i", "-c", hex(w["id"])], capture_output=True)
|
|
106
|
+
return w
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def active_window() -> dict:
|
|
110
|
+
for w in list_windows():
|
|
111
|
+
if w["active"]:
|
|
112
|
+
return w
|
|
113
|
+
raise LookupError("no active window")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
PROFILE_DIR = os.path.join(os.path.expanduser("~"), ".cache", "automonkey", "profiles")
|
|
117
|
+
|
|
118
|
+
# Quiet automation profile: no first-run tabs, default-browser nags or translation popups.
|
|
119
|
+
FIREFOX_PREFS = """\
|
|
120
|
+
user_pref("browser.aboutwelcome.enabled", false);
|
|
121
|
+
user_pref("browser.shell.checkDefaultBrowser", false);
|
|
122
|
+
user_pref("browser.startup.homepage_override.mstone", "ignore");
|
|
123
|
+
user_pref("startup.homepage_welcome_url", "");
|
|
124
|
+
user_pref("startup.homepage_welcome_url.additional", "");
|
|
125
|
+
user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
|
|
126
|
+
user_pref("datareporting.policy.firstRunURL", "");
|
|
127
|
+
user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
|
|
128
|
+
user_pref("browser.translations.automaticallyPopup", false);
|
|
129
|
+
user_pref("browser.translations.panelShown", true);
|
|
130
|
+
user_pref("browser.tabs.warnOnClose", false);
|
|
131
|
+
user_pref("browser.sessionstore.resume_from_crash", false);
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _separate_browser_instance(args: list[str]) -> list[str]:
|
|
136
|
+
"""Browsers hand a launch over to an already running instance of the same profile.
|
|
137
|
+
If the user's own browser runs as a native Wayland window, our window would end
|
|
138
|
+
up there and be uncontrollable, so use a dedicated automation profile instead.
|
|
139
|
+
Relaunching later reuses that (X11) instance, which is what we want."""
|
|
140
|
+
name = os.path.basename(args[0])
|
|
141
|
+
if name in FIREFOX_LIKE and not {"-P", "--profile", "-profile"} & set(args):
|
|
142
|
+
path = os.path.join(PROFILE_DIR, name)
|
|
143
|
+
os.makedirs(path, exist_ok=True)
|
|
144
|
+
user_js = os.path.join(path, "user.js")
|
|
145
|
+
if not os.path.exists(user_js):
|
|
146
|
+
with open(user_js, "w", encoding="utf-8") as f:
|
|
147
|
+
f.write(FIREFOX_PREFS)
|
|
148
|
+
return [args[0], "--profile", path, *args[1:]]
|
|
149
|
+
if name in CHROMIUM_LIKE:
|
|
150
|
+
extra = ["--ozone-platform=x11"]
|
|
151
|
+
if not any(a.startswith("--user-data-dir") for a in args):
|
|
152
|
+
path = os.path.join(PROFILE_DIR, name)
|
|
153
|
+
os.makedirs(path, exist_ok=True)
|
|
154
|
+
extra.append(f"--user-data-dir={path}")
|
|
155
|
+
return [args[0], *extra, *args[1:]]
|
|
156
|
+
return args
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def launch(command: str, timeout: float = 60.0) -> dict:
|
|
160
|
+
"""Start `command` detached and return its first new window once it is visible."""
|
|
161
|
+
before = {w["id"] for w in list_windows()}
|
|
162
|
+
args = shlex.split(command)
|
|
163
|
+
env = dict(os.environ)
|
|
164
|
+
if session_type() == "wayland":
|
|
165
|
+
env.update(X11_ENV)
|
|
166
|
+
args = _separate_browser_instance(args)
|
|
167
|
+
kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env)
|
|
168
|
+
if sys.platform == "win32":
|
|
169
|
+
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
170
|
+
else:
|
|
171
|
+
kwargs["start_new_session"] = True
|
|
172
|
+
subprocess.Popen(args, **kwargs)
|
|
173
|
+
start = time.monotonic()
|
|
174
|
+
while time.monotonic() - start < timeout:
|
|
175
|
+
new = [w for w in list_windows() if w["id"] not in before]
|
|
176
|
+
if new:
|
|
177
|
+
return new[-1]
|
|
178
|
+
time.sleep(0.3)
|
|
179
|
+
raise TimeoutError(f"no new window appeared within {timeout:.0f}s after launching {command!r} "
|
|
180
|
+
f"(on Wayland the app may have opened as a native window that can't be controlled)")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def format_windows(wins: list[dict]) -> str:
|
|
184
|
+
return "\n".join(f"{'*' if w['active'] else ' '} {w['id']:>10} {w['wm_class'][:30]:<30} {w['title']}"
|
|
185
|
+
for w in wins) or "no windows"
|