tapwright 1.0.0__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.
tapwright/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """tapwright: let LLM agents test apps on your real iPhone."""
2
+ __version__ = "1.0.0"
tapwright/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
tapwright/airplay.py ADDED
@@ -0,0 +1,103 @@
1
+ """The AirPlay receiver for the H.264 live view: UxPlay, an open-source program run as its own process. It takes
2
+ the phone's Screen Mirroring and passes the phone's own H.264 on, undecoded, as RTP to video.VideoHub on
3
+ localhost. Tapwright doesn't bundle it: `find()` looks for an installed one.
4
+
5
+ How it is run (the size, 60 frames a second, a fixed device id) follows phoneuse (MIT), where it was worked
6
+ out on Windows; the numbers were measured again here on an iPhone 14 Pro Max, iOS 26.5 (2026-11-10)."""
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import threading
11
+ from pathlib import Path
12
+ from typing import Callable
13
+
14
+ from . import store
15
+
16
+ NAME = "Tapwright" # what the phone lists under Screen Mirroring
17
+ WINDOWS_BUILD = Path(r"C:\msys64\ucrt64\bin\uxplay.exe") # where an MSYS2 build of UxPlay lands
18
+ STARTED = ("begin streaming", "starting mirroring") # UxPlay's log lines when the phone starts mirroring...
19
+ ENDED = ("no longer true", "connection closed") # ...and when it stops
20
+
21
+
22
+ def find() -> str | None:
23
+ """The UxPlay to run: $TAPWRIGHT_UXPLAY, else `uxplay` on the PATH, else the MSYS2 build on Windows."""
24
+ named = os.environ.get("TAPWRIGHT_UXPLAY")
25
+ if named:
26
+ return named if Path(named).is_file() else None
27
+ found = shutil.which("uxplay")
28
+ if found:
29
+ return found
30
+ return str(WINDOWS_BUILD) if os.name == "nt" and WINDOWS_BUILD.is_file() else None
31
+
32
+
33
+ def device_id() -> str:
34
+ """A fixed AirPlay device id for this computer (a random, locally administered MAC address, made once), so
35
+ the phone sees one receiver across runs. UxPlay would otherwise take an adapter's address, which can change."""
36
+ path = store.HOME / "airplay-id"
37
+ try:
38
+ mac = path.read_text().strip()
39
+ if len(mac) == 17:
40
+ return mac
41
+ except OSError:
42
+ pass
43
+ raw = bytearray(os.urandom(6))
44
+ raw[0] = (raw[0] & 0xFC) | 0x02 # unicast, locally administered
45
+ mac = ":".join(f"{b:02x}" for b in raw)
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ path.write_text(mac)
48
+ return mac
49
+
50
+
51
+ def command(uxplay: str, rtp_port: int, pixels: tuple[int, int], name: str = NAME) -> list[str]:
52
+ """UxPlay's command line: fixed ports, our name, the phone's H.264 as RTP to `rtp_port`, no audio."""
53
+ return [uxplay, "-p", "-n", name, "-nh",
54
+ "-vrtp", f"config-interval=1 pt=96 mtu=60000 ! udpsink host=127.0.0.1 port={rtp_port} sync=false",
55
+ "-vsync", "no", "-s", f"{pixels[0]}x{pixels[1]}@60", "-fps", "60", "-m", device_id(), "-a"]
56
+
57
+
58
+ class Receiver:
59
+ """UxPlay as a child process. `mirroring` follows what its log says about the phone's connection."""
60
+
61
+ def __init__(self, uxplay: str, rtp_port: int, pixels: tuple[int, int],
62
+ log: Callable[[str], None] | None = None, name: str = NAME) -> None:
63
+ self.args = command(uxplay, rtp_port, pixels, name)
64
+ self.name = name
65
+ self.mirroring = False
66
+ self._log = log or (lambda message: None)
67
+ self._process: subprocess.Popen | None = None
68
+
69
+ def start(self) -> None:
70
+ env = dict(os.environ)
71
+ env["PATH"] = os.path.dirname(self.args[0]) + os.pathsep + env.get("PATH", "") # its DLLs sit beside it
72
+ flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
73
+ self._process = subprocess.Popen(self.args, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
74
+ stderr=subprocess.STDOUT, creationflags=flags)
75
+ threading.Thread(target=self._follow, args=(self._process,), name="uxplay-log", daemon=True).start()
76
+
77
+ def _follow(self, process: subprocess.Popen) -> None:
78
+ for raw in process.stdout:
79
+ line = raw.decode(errors="replace").strip().lower()
80
+ if any(mark in line for mark in STARTED):
81
+ if not self.mirroring:
82
+ self._log("video: the phone is mirroring")
83
+ self.mirroring = True
84
+ elif any(mark in line for mark in ENDED):
85
+ if self.mirroring:
86
+ self._log("video: the phone stopped mirroring")
87
+ self.mirroring = False
88
+ self.mirroring = False
89
+
90
+ @property
91
+ def running(self) -> bool:
92
+ return self._process is not None and self._process.poll() is None
93
+
94
+ def stop(self) -> None:
95
+ process, self._process = self._process, None
96
+ self.mirroring = False
97
+ if process is None or process.poll() is not None:
98
+ return
99
+ process.terminate()
100
+ try:
101
+ process.wait(5)
102
+ except subprocess.TimeoutExpired:
103
+ process.kill()
tapwright/changes.py ADDED
@@ -0,0 +1,264 @@
1
+ """Refs that survive an action, and the change list an action answers with (docs/AGENT-TOOLS.md
2
+ §3.1 and §3.4). Pure functions over element lists, so they're testable without a phone."""
3
+ import math
4
+ from dataclasses import dataclass, replace
5
+
6
+ from .ui import Element, clip
7
+
8
+ NO_CHANGE = "No visible change."
9
+ SAME_SCREEN = "Same screen. Changes:"
10
+ MOVE_PT = 1.0 # a frame moved when x, y, width or height differ by more than this many points (WDA's
11
+ # XML has whole points, so rounding alone can move a value by 1)
12
+ MOVED_LISTED = 5 # up to this many moved elements get a line each; more become a count
13
+ FULL_MIN = 8 # the full list replaces the changes above max(FULL_MIN, half the new list)
14
+
15
+
16
+ def highest(items: list[Element]) -> int:
17
+ """The largest number among the refs e1, e2 ... of a list; 0 for none."""
18
+ return max((int(e.ref[1:]) for e in items if e.ref[1:].isdigit()), default=0)
19
+
20
+
21
+ def _key(e: Element) -> tuple[str, str, str]:
22
+ """What makes two elements "the same one" on different reads: the identifier, else the text (label,
23
+ else value)."""
24
+ return (e.role, "id", e.identifier) if e.identifier else (e.role, "text", e.text)
25
+
26
+
27
+ def _stayed(a: Element, b: Element) -> bool:
28
+ return math.hypot(a.center[0] - b.center[0], a.center[1] - b.center[1]) <= MOVE_PT
29
+
30
+
31
+ def _shift(a: Element, b: Element) -> tuple[float, float]:
32
+ return b.x - a.x, b.y - a.y
33
+
34
+
35
+ def _moved_by(a: Element, b: Element, shift: tuple[float, float]) -> bool:
36
+ dx, dy = _shift(a, b)
37
+ return abs(dx - shift[0]) <= MOVE_PT and abs(dy - shift[1]) <= MOVE_PT
38
+
39
+
40
+ def _content_shift(pairs: list[tuple[Element, Element]]) -> tuple[float, float] | None:
41
+ """How far the content moved: the displacement most unique elements with an identifier or text agree on
42
+ (within a point), if at least three do and it is a real move; else None."""
43
+ shifts = [_shift(a, b) for a, b in pairs if a.identifier or a.text]
44
+ best = max(shifts, key=lambda s: sum(abs(s[0] - t[0]) <= MOVE_PT and abs(s[1] - t[1]) <= MOVE_PT for t in shifts),
45
+ default=None)
46
+ if best is None:
47
+ return None
48
+ agreeing = sum(abs(best[0] - t[0]) <= MOVE_PT and abs(best[1] - t[1]) <= MOVE_PT for t in shifts)
49
+ if agreeing < 3 or (abs(best[0]) <= MOVE_PT and abs(best[1]) <= MOVE_PT):
50
+ return None # too few agree, or they all stayed (fixed bars): nothing says the content moved
51
+ return round(best[0]), round(best[1])
52
+
53
+
54
+ def carry_refs(old: list[Element], new: list[Element], used: int = 0,
55
+ moves_content: bool | None = False) -> list[Element]:
56
+ """`new` (a fresh read, in reading order) with the refs of the elements still on screen, and new
57
+ numbers for the others, counted on from the highest ref in use. `used` is the highest number this
58
+ agent was ever given, so a ref that went away is never handed to another element.
59
+
60
+ `moves_content` None is a read with no action of the agent's before it (the re-read a target is
61
+ resolved on): the content counts as moved only if the unique elements agree that it did. Calling
62
+ every such read a move gave each element without an identifier a new ref on almost every answer.
63
+
64
+ A key (see _key) that is unique in both lists keeps its ref however far the element moved, except
65
+ an element with neither identifier nor text, which keeps it only if it stayed put. A key shared by
66
+ several elements keeps its refs only if the group is the same size in both lists, every member
67
+ stayed put, the action wasn't one that moves content (`moves_content`: scroll, swipe, back, open_app,
68
+ open_url) and no unique element moved: positions alone can't tell "Edit" of row A from "Edit" of row B
69
+ after a scroll by one row, and a wrong guess presses the wrong row. Else every member is new."""
70
+ before: dict[tuple[str, str, str], list[Element]] = {}
71
+ after: dict[tuple[str, str, str], list[Element]] = {}
72
+ for e in old:
73
+ before.setdefault(_key(e), []).append(e)
74
+ for e in new:
75
+ after.setdefault(_key(e), []).append(e)
76
+ kept: dict[int, str] = {} # id(new element) -> the ref it keeps
77
+ pairs = [(before[key][0], group[0]) for key, group in after.items()
78
+ if len(group) == 1 and len(before.get(key, [])) == 1]
79
+ unknown = moves_content is None
80
+ if unknown:
81
+ moves_content = _content_shift(pairs) is not None
82
+ content_moved = moves_content
83
+ shift = _content_shift(pairs) if moves_content else None
84
+ for was, now in pairs:
85
+ if moves_content and not was.identifier:
86
+ # no identifier: a same-named element elsewhere may be another row's. It keeps its ref only
87
+ # if it moved the way the content did; with no estimate of that, never.
88
+ ok = shift is not None and _moved_by(was, now, shift)
89
+ elif unknown:
90
+ # nothing says the content moved, and no action says it shouldn't have: the same text somewhere
91
+ # else may be another row's (a list that scrolled by itself, by less than three rows we can match)
92
+ ok = bool(_key(was)[1] == "id" or _stayed(was, now))
93
+ else:
94
+ ok = bool(_key(was)[1] == "id" or was.text or _stayed(was, now))
95
+ if ok:
96
+ kept[id(now)] = was.ref
97
+ content_moved = content_moved or not _stayed(was, now)
98
+ for key, group in after.items():
99
+ was = before.get(key, [])
100
+ if (not content_moved and not (len(was) == 1 and len(group) == 1) and len(was) == len(group)
101
+ and all(_stayed(o, n) for o, n in zip(was, group))):
102
+ kept.update((id(n), o.ref) for o, n in zip(was, group))
103
+ last = max(highest(old), used)
104
+ out: list[Element] = []
105
+ for e in new:
106
+ ref = kept.get(id(e))
107
+ if ref is None:
108
+ last += 1
109
+ ref = f"e{last}"
110
+ out.append(replace(e, ref=ref))
111
+ return out
112
+
113
+
114
+ def _flag(v: bool) -> str:
115
+ return f'"{str(v).lower()}"'
116
+
117
+
118
+ def _was(old: Element, new: Element) -> list[str]:
119
+ """`field=<old value>` for each of text, value, checked, selected, disabled and actions that changed.
120
+ A value that only mirrors the text, and a switch's value (it is `checked`), aren't fields of their own.
121
+ The frame is never one: a moved or resized element is a `↕`."""
122
+ out = []
123
+ if old.text != new.text:
124
+ out.append(("text", clip(old.text)))
125
+ if (old.value != new.value and old.checked is None and new.checked is None
126
+ and (old.value != old.text or new.value != new.text)):
127
+ # an input that showed its placeholder: `(was placeholder "…")` (AGENT-TOOLS v1.7)
128
+ out.append(("value", f"placeholder {clip(old.placeholder)}" if old.shows_placeholder else clip(old.value)))
129
+ if old.checked != new.checked:
130
+ out.append(("checked", "null" if old.checked is None else _flag(old.checked)))
131
+ if old.selected != new.selected:
132
+ out.append(("selected", _flag(old.selected)))
133
+ if old.enabled != new.enabled:
134
+ out.append(("disabled", _flag(not old.enabled)))
135
+ if old.actions != new.actions:
136
+ out.append(("actions", clip(", ".join(old.actions))))
137
+ return [f"{name}={v}" for name, v in out] if len(out) > 1 else [v for _, v in out]
138
+
139
+
140
+ def _gone(e: Element) -> str:
141
+ """`- <role> "<text>"`, else `<role> id=<id>`, else the role alone: no ref, no position."""
142
+ if e.text:
143
+ return f"- {e.role} {clip(e.text)}"
144
+ return f"- {e.role} id={e.identifier}" if e.identifier else f"- {e.role}"
145
+
146
+
147
+ def _frame_moved(old: Element, new: Element) -> bool:
148
+ return any(abs(a - b) > MOVE_PT for a, b in zip(old.rect, new.rect))
149
+
150
+
151
+ def _changes(old: list[Element], new: list[Element]) -> tuple[list[str], int]:
152
+ """(the change lines, how many elements changed) between two lists whose refs agree (see carry_refs)."""
153
+ before, after = {e.ref: e for e in old}, {e.ref: e for e in new}
154
+ added = [f"+ {e.line()}" for e in new if e.ref not in before]
155
+ gone = [_gone(e) for e in old if e.ref not in after]
156
+ modified, moved = [], []
157
+ for e in new:
158
+ o = before.get(e.ref)
159
+ if o is None:
160
+ continue
161
+ was = _was(o, e)
162
+ if was:
163
+ modified.append(f"~ {e.line()} (was {', '.join(was)})")
164
+ elif _frame_moved(o, e):
165
+ moved.append(e)
166
+ lines = added + gone + modified
167
+ if len(moved) > MOVED_LISTED:
168
+ lines.append(f"↕ {len(moved)} elements moved")
169
+ else:
170
+ lines += [f"↕ {e.line()}" for e in moved]
171
+ return lines or [NO_CHANGE], len(added) + len(gone) + len(modified) + len(moved)
172
+
173
+
174
+ OFF_LISTED = 5 # up to this many changed off-screen elements get a line each; more become a count
175
+ OFF_SCREEN = "Changes off screen:" # heads those lines when they follow a full list (in a change block they need none)
176
+
177
+
178
+ def off_screen(old: list[Element], new: list[Element], by_place: bool = True,
179
+ listed: list[Element] | None = None) -> list[str]:
180
+ """`~` lines for the elements off screen whose text, value, checked, selected or disabled changed between two
181
+ reads (AGENT-TOOLS v1.6): a tap whose effect lands on something scrolled out of view answered "No visible
182
+ change.", which read as "nothing happened". No refs (what is off screen has none), and only `~`: off-screen
183
+ elements that are new, gone or moved aren't listed, since a scroll moves all of them.
184
+
185
+ Off screen an element is the same one when its identifier is, if that is unique among what is off screen in
186
+ both reads; else, with `by_place`, when its role and place are (not after an action that moves content: the
187
+ next row then sits where this one sat).
188
+
189
+ `listed` is the agent's last list: an element that was on it and is off screen now (a scroll answered without
190
+ a list came between) is compared with what it was there, by its identifier."""
191
+ def unique(items: list[Element]) -> dict[tuple[str, str], Element]:
192
+ seen: dict[tuple[str, str], list[Element]] = {}
193
+ for e in items:
194
+ if e.identifier:
195
+ seen.setdefault((e.role, e.identifier), []).append(e)
196
+ return {key: group[0] for key, group in seen.items() if len(group) == 1}
197
+
198
+ def place(e: Element) -> tuple:
199
+ return (e.role, e.identifier, *(round(v) for v in e.rect))
200
+ was_off, placed = unique(old), {place(e): e for e in old}
201
+ named, now_named = unique([*old, *(listed or [])]), unique(new)
202
+ # Another screen of the same app can reuse an identifier ("title", "footer"): when fewer than half of the
203
+ # identifiers that were off screen still are, this isn't the same content, and nothing is said about it.
204
+ if was_off and len(was_off.keys() & now_named.keys()) * 2 < len(was_off):
205
+ return []
206
+ # A place names an element only while every element off screen is where it was. A row added or removed, or a
207
+ # form that slid up for the keyboard, moves them all by one row, and each would look like its neighbour changed.
208
+ by_place = by_place and placed.keys() == {place(e) for e in new}
209
+ lines = []
210
+ for e in new:
211
+ key = (e.role, e.identifier)
212
+ was = named.get(key) if now_named.get(key) is e else (placed.get(place(e)) if by_place else None)
213
+ if was is None:
214
+ continue
215
+ fields = _was(was, replace(e, actions=was.actions)) # what it can do isn't news off screen
216
+ if fields:
217
+ lines.append(f"~ {e.line(ref=False, actions=False)} (off screen; was {', '.join(fields)})")
218
+ if len(lines) > OFF_LISTED:
219
+ lines = [*lines[:OFF_LISTED], f"~ {len(lines) - OFF_LISTED} more changed off screen"]
220
+ return lines
221
+
222
+
223
+ def diff(old: list[Element], new: list[Element]) -> list[str]:
224
+ """The change lines from `old` to `new`, whose refs came from carry_refs: `+` new, `-` gone (no ref),
225
+ `~` changed (with the old value), `↕` moved; or just "No visible change."."""
226
+ return _changes(old, new)[0]
227
+
228
+
229
+ @dataclass
230
+ class Update:
231
+ """What an action answers with, and the list the agent holds afterwards."""
232
+ items: list[Element]
233
+ full: bool # the full list replaces the changes
234
+ lines: list[str] # the change block, headed by "Same screen. Changes:"; empty when `full`
235
+ top: int # the highest ref number issued so far
236
+
237
+
238
+ def update(old: list[Element], new: list[Element], app_changed: bool = False, used: int = 0,
239
+ shown: list[Element] | None = None, full: bool = False, header: list[str] | None = None,
240
+ moves_content: bool | None = False) -> Update:
241
+ """After an action: carry the refs over, and say what changed. The full list instead when the app changed,
242
+ when there was no list before, or when more than max(8, half the new list) of the elements changed, or
243
+ when nothing is left of a list that had elements (a column of `-` lines would only hide that the screen is
244
+ covered). A full list keeps numbering too (AGENT-TOOLS v1.4): what stayed keeps its ref and everything else
245
+ counts on from the highest ref given, so an older ref never lands on another element. Only `look` starts
246
+ again at e1. After another app came to the front, or when `full` is forced, nothing is carried.
247
+ `old` is the list whose refs carry; `shown` is what the agent was last told, when a silent re-read
248
+ moved `old` past it: the changes are measured against that, so nothing it hasn't heard of is lost.
249
+ `full` forces the full list (a person acted, the line dropped, the agent never looked).
250
+ `header` lines (an alert or the keyboard changed) come first in the change block."""
251
+ base = old if shown is None else shown
252
+ if full or app_changed:
253
+ fresh = carry_refs([], new, max(used, highest(old)))
254
+ return Update(fresh, True, [], max(used, highest(old), highest(fresh)))
255
+ carried = carry_refs(old, new, used, moves_content)
256
+ top = max(used, highest(old), highest(carried))
257
+ if (not base and bool(new)) or (bool(base) and not new):
258
+ return Update(carried, True, [], top)
259
+ lines, count = _changes(base, carried)
260
+ if count > max(FULL_MIN, len(new) / 2):
261
+ return Update(carried, True, [], top)
262
+ if header:
263
+ lines = [*header, *([] if lines == [NO_CHANGE] else lines)]
264
+ return Update(carried, False, [SAME_SCREEN, *lines], top)