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.
@@ -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}"
@@ -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
@@ -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"