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 +2 -0
- tapwright/__main__.py +5 -0
- tapwright/airplay.py +103 -0
- tapwright/changes.py +264 -0
- tapwright/cli.py +374 -0
- tapwright/client.py +151 -0
- tapwright/control.py +189 -0
- tapwright/daemon.py +2401 -0
- tapwright/descriptions.py +186 -0
- tapwright/device.py +495 -0
- tapwright/errors.py +13 -0
- tapwright/mcp_server.py +266 -0
- tapwright/mirror.py +91 -0
- tapwright/rtp.py +109 -0
- tapwright/server.py +435 -0
- tapwright/session.py +672 -0
- tapwright/shot.py +42 -0
- tapwright/signing.py +201 -0
- tapwright/skill/SKILL.md +203 -0
- tapwright/skill/agents/openai.yaml +4 -0
- tapwright/skill/references/setup.md +102 -0
- tapwright/skills.py +60 -0
- tapwright/store.py +130 -0
- tapwright/stream.py +405 -0
- tapwright/tapline.py +118 -0
- tapwright/ui.py +1242 -0
- tapwright/video.py +486 -0
- tapwright/viewer.html +2060 -0
- tapwright-1.0.0.dist-info/METADATA +254 -0
- tapwright-1.0.0.dist-info/RECORD +34 -0
- tapwright-1.0.0.dist-info/WHEEL +5 -0
- tapwright-1.0.0.dist-info/entry_points.txt +2 -0
- tapwright-1.0.0.dist-info/licenses/LICENSE +21 -0
- tapwright-1.0.0.dist-info/top_level.txt +1 -0
tapwright/cli.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""tapwright: let LLM agents test apps on your real iPhone, from Windows, Linux or macOS.
|
|
2
|
+
|
|
3
|
+
tapwright setup one time, phone on USB: pair, sign and install WebDriverAgent,
|
|
4
|
+
pair for network use. Signing: --asc-key-id/--asc-issuer-id/--asc-key-file
|
|
5
|
+
with --bundle-prefix (App Store Connect API key), or --p12/--profile
|
|
6
|
+
tapwright doctor check the setup and say what's missing
|
|
7
|
+
tapwright smoke --app BUNDLE_ID
|
|
8
|
+
open an app, read its first screen, save a screenshot, close it
|
|
9
|
+
tapwright probe [--host IP] [--watch]
|
|
10
|
+
does it work over the network? Learns the pairing port on the local
|
|
11
|
+
Wi-Fi; with --host (e.g. the phone's Tailscale IP) tries that address
|
|
12
|
+
tapwright serve [--phone ADDRESS] [--port N] [--keep-alive] [--new-token] [--stop]
|
|
13
|
+
the background process that owns the phone: the live view in your
|
|
14
|
+
browser, the agent tools and an HTTP API. Agent tools start it by
|
|
15
|
+
themselves; it stops after 30 minutes unused (--keep-alive: never)
|
|
16
|
+
tapwright view open the live view in your browser (starts the daemon if needed)
|
|
17
|
+
tapwright mcp [--screenshots DIR] the MCP server for agents (stdio); it talks to the daemon
|
|
18
|
+
|
|
19
|
+
Connection: USB when the phone is plugged in, else the local network; serve --phone dials an
|
|
20
|
+
address such as the phone's Tailscale IP (experimental).
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
import socket
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from . import store
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ok(msg):
|
|
34
|
+
print(f" ok {msg}", flush=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def note(msg):
|
|
38
|
+
print(f" note {msg}", flush=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fail(msg):
|
|
42
|
+
print(f" FAIL {msg}", flush=True)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _daemon_owns_phone() -> bool:
|
|
47
|
+
from . import client
|
|
48
|
+
return client.healthy(store.read_serve_info()) is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DAEMON_RUNNING = "tapwright serve is running and owns the phone: stop it first with `tapwright serve --stop`"
|
|
52
|
+
INSTALL_WAIT = 180.0 # seconds the phone gets to install WebDriverAgent (it takes about 20)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------- setup ----------
|
|
56
|
+
|
|
57
|
+
async def setup(a) -> int:
|
|
58
|
+
from pymobiledevice3 import usbmux
|
|
59
|
+
from pymobiledevice3.exceptions import RemotePairingCompletedError
|
|
60
|
+
from pymobiledevice3.lockdown import create_using_usbmux
|
|
61
|
+
from pymobiledevice3.remote.tunnel_service import RemotePairingLockdownService
|
|
62
|
+
from pymobiledevice3.services.installation_proxy import InstallationProxyService
|
|
63
|
+
|
|
64
|
+
from . import signing
|
|
65
|
+
if _daemon_owns_phone(): # its WebDriverAgent is running: iOS would hold the new one's install open
|
|
66
|
+
return fail(DAEMON_RUNNING)
|
|
67
|
+
print("1. The iPhone, on USB")
|
|
68
|
+
usb = [d for d in await usbmux.list_devices() if d.connection_type == "USB"]
|
|
69
|
+
if not usb:
|
|
70
|
+
return fail("no iPhone on USB. Plug it in and unlock it. (Windows: install Apple Devices from the "
|
|
71
|
+
"Microsoft Store; Linux: usbmuxd.)")
|
|
72
|
+
udid = a.udid or usb[0].serial
|
|
73
|
+
lockdown = await create_using_usbmux(serial=udid, autopair=True) # the phone asks "Trust This Computer?"
|
|
74
|
+
name = lockdown.all_values.get("DeviceName") or "iPhone"
|
|
75
|
+
ok(f"{name} ({lockdown.display_name or lockdown.product_type}, iOS {lockdown.product_version}) {udid}")
|
|
76
|
+
|
|
77
|
+
print("2. Developer Mode")
|
|
78
|
+
if not await lockdown.get_developer_mode_status():
|
|
79
|
+
from pymobiledevice3.services.amfi import AmfiService
|
|
80
|
+
await AmfiService(lockdown).reveal_developer_mode_option_in_ui()
|
|
81
|
+
return fail("Developer Mode is off. On the iPhone: Settings > Privacy & Security > Developer Mode, turn "
|
|
82
|
+
"it on, restart, confirm Turn On. Then run setup again.")
|
|
83
|
+
ok("on")
|
|
84
|
+
|
|
85
|
+
print("3. Signing identity")
|
|
86
|
+
if a.asc_key_id:
|
|
87
|
+
if not a.bundle_prefix:
|
|
88
|
+
return fail("--bundle-prefix is needed with an API key, e.g. com.yourname.tapwright "
|
|
89
|
+
"(the App ID com.yourname.tapwright.* is created for WebDriverAgent)")
|
|
90
|
+
signing.create_identity(signing.AppStoreConnect(a.asc_key_id, a.asc_issuer_id, a.asc_key_file),
|
|
91
|
+
udid, a.bundle_prefix)
|
|
92
|
+
ok("development certificate and profile from App Store Connect")
|
|
93
|
+
elif a.p12:
|
|
94
|
+
signing.import_identity(a.p12, a.p12_password or "", a.profile)
|
|
95
|
+
ok("imported your certificate and profile")
|
|
96
|
+
elif not signing.PROFILE_FILE.exists():
|
|
97
|
+
return fail("no signing identity yet: pass an App Store Connect API key (--asc-key-id, --asc-issuer-id, "
|
|
98
|
+
"--asc-key-file, --bundle-prefix) or --p12 and --profile. See docs/setup.md.")
|
|
99
|
+
profile = signing.read_profile()
|
|
100
|
+
if udid not in profile.get("ProvisionedDevices", []):
|
|
101
|
+
return fail("the profile doesn't include this iPhone; make a new one that does (with an API key, "
|
|
102
|
+
"setup does it)")
|
|
103
|
+
ok(f"profile '{profile['Name']}', expires {profile['ExpirationDate']:%Y-%m-%d}")
|
|
104
|
+
|
|
105
|
+
print("4. WebDriverAgent")
|
|
106
|
+
ipa, runner_id = signing.sign_wda(store.HOME / "build")
|
|
107
|
+
try:
|
|
108
|
+
async with InstallationProxyService(lockdown=lockdown) as proxy:
|
|
109
|
+
await asyncio.wait_for(proxy.install_from_local(ipa), INSTALL_WAIT)
|
|
110
|
+
except asyncio.TimeoutError:
|
|
111
|
+
# iOS keeps an install open while the app it replaces still runs (seen 2026-11-11, IXErrorDomain 48)
|
|
112
|
+
return fail(f"the phone didn't finish installing WebDriverAgent in {INSTALL_WAIT:.0f} s. If a test runner "
|
|
113
|
+
"is still running on the phone (another tool, or Xcode), stop it, then run setup again.")
|
|
114
|
+
ok(f"installed as {runner_id}")
|
|
115
|
+
|
|
116
|
+
print("5. Network pairing (Wi-Fi and remote use)")
|
|
117
|
+
service = await RemotePairingLockdownService.create(lockdown)
|
|
118
|
+
try:
|
|
119
|
+
await service.connect(autopair=True)
|
|
120
|
+
except RemotePairingCompletedError:
|
|
121
|
+
pass
|
|
122
|
+
finally:
|
|
123
|
+
await service.close()
|
|
124
|
+
ok("paired")
|
|
125
|
+
store.save_state(udid=udid, runner_id=runner_id, phone=name)
|
|
126
|
+
print("\nDone. Try: tapwright smoke --app <bundle id of one of your apps>")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------- doctor ----------
|
|
131
|
+
|
|
132
|
+
async def doctor(a) -> int:
|
|
133
|
+
from importlib.metadata import version
|
|
134
|
+
from pymobiledevice3 import usbmux
|
|
135
|
+
|
|
136
|
+
from . import signing
|
|
137
|
+
problems = 0
|
|
138
|
+
ok(f"Python {sys.version.split()[0]}, pymobiledevice3 {version('pymobiledevice3')}")
|
|
139
|
+
try:
|
|
140
|
+
devices = await usbmux.list_devices()
|
|
141
|
+
ok(f"device service reachable; on USB: {[d.serial for d in devices if d.connection_type == 'USB'] or 'none'}")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
problems += fail(f"device service unreachable ({e}). Windows: install Apple Devices (Microsoft Store); "
|
|
144
|
+
"Linux: usbmuxd")
|
|
145
|
+
if signing.PROFILE_FILE.exists():
|
|
146
|
+
p = signing.read_profile()
|
|
147
|
+
expired = p["ExpirationDate"].timestamp() < time.time()
|
|
148
|
+
(fail if expired else ok)(f"profile '{p['Name']}' expires {p['ExpirationDate']:%Y-%m-%d}, "
|
|
149
|
+
f"{len(p.get('ProvisionedDevices', []))} device(s)")
|
|
150
|
+
problems += expired
|
|
151
|
+
else:
|
|
152
|
+
problems += fail("no signing identity: run tapwright setup")
|
|
153
|
+
state = store.load_state()
|
|
154
|
+
if state.get("runner_id"):
|
|
155
|
+
ok(f"WebDriverAgent set up for {state.get('phone') or state.get('udid')}")
|
|
156
|
+
else:
|
|
157
|
+
problems += fail("WebDriverAgent not installed yet: run tapwright setup (phone on USB)")
|
|
158
|
+
if state.get("pairing_port"):
|
|
159
|
+
ok(f"network pairing port known: {state['pairing_port']} (tapwright probe refreshes it)")
|
|
160
|
+
else:
|
|
161
|
+
note("network pairing port unknown: run tapwright probe with the phone on the same Wi-Fi")
|
|
162
|
+
print(f"\n{problems} problem(s)." if problems else "\nAll set.")
|
|
163
|
+
return 1 if problems else 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------- probe: does it work over the network / a VPN? ----------
|
|
167
|
+
|
|
168
|
+
def _tcp(host: str, port: int, timeout: float = 3.0) -> str:
|
|
169
|
+
s = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET)
|
|
170
|
+
s.settimeout(timeout)
|
|
171
|
+
try:
|
|
172
|
+
s.connect((host.split("%")[0], port))
|
|
173
|
+
return "open"
|
|
174
|
+
except socket.timeout:
|
|
175
|
+
return "no answer"
|
|
176
|
+
except OSError:
|
|
177
|
+
return "refused"
|
|
178
|
+
finally:
|
|
179
|
+
s.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def probe(a) -> int:
|
|
183
|
+
if _daemon_owns_phone():
|
|
184
|
+
return fail(DAEMON_RUNNING)
|
|
185
|
+
from pymobiledevice3.bonjour import browse_remotepairing
|
|
186
|
+
|
|
187
|
+
from .device import Device
|
|
188
|
+
from .session import Session
|
|
189
|
+
print("1. The phone's network pairing service on this Wi-Fi (mDNS)")
|
|
190
|
+
found = await browse_remotepairing(timeout=6)
|
|
191
|
+
if found:
|
|
192
|
+
port = found[0].port
|
|
193
|
+
store.save_state(pairing_port=port)
|
|
194
|
+
ok(f"port {port} at {[x.full_ip for x in found[0].addresses]}")
|
|
195
|
+
else:
|
|
196
|
+
port = store.load_state().get("pairing_port")
|
|
197
|
+
note(f"not found on this network; using the last known port {port}" if port else
|
|
198
|
+
"not found, and no known port: put the phone on the same Wi-Fi once")
|
|
199
|
+
if not port:
|
|
200
|
+
return 1
|
|
201
|
+
if a.host:
|
|
202
|
+
print(f"2. Reachable at {a.host}?")
|
|
203
|
+
reach = _tcp(a.host, port)
|
|
204
|
+
if reach != "open":
|
|
205
|
+
return fail(f"{a.host}:{port} {reach}: the phone doesn't offer its developer service at that address")
|
|
206
|
+
ok(f"{a.host}:{port} open")
|
|
207
|
+
print("3. Developer tunnel, WebDriverAgent, a screenshot")
|
|
208
|
+
session = Session(Device(host=a.host, port=port if a.host else None))
|
|
209
|
+
try:
|
|
210
|
+
t0 = time.time()
|
|
211
|
+
text, image = await session.look()
|
|
212
|
+
ok(f"over {session.device.via} in {time.time() - t0:.1f} s; {text.splitlines()[0]}")
|
|
213
|
+
shot = Path(f"tapwright-probe-{int(time.time())}.jpg")
|
|
214
|
+
shot.write_bytes(image)
|
|
215
|
+
ok(f"screenshot: {shot}")
|
|
216
|
+
if a.watch:
|
|
217
|
+
print("4. Watching (Ctrl+C stops). Now change the phone's network (e.g. Wi-Fi off).")
|
|
218
|
+
while True:
|
|
219
|
+
alive = await session.device.wda_alive()
|
|
220
|
+
print(f" {time.strftime('%H:%M:%S')} {'alive' if alive else 'LOST'}", flush=True)
|
|
221
|
+
await asyncio.sleep(2)
|
|
222
|
+
return 0
|
|
223
|
+
finally:
|
|
224
|
+
await session.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------- smoke test ----------
|
|
228
|
+
|
|
229
|
+
async def smoke(a) -> int:
|
|
230
|
+
if _daemon_owns_phone():
|
|
231
|
+
return fail(DAEMON_RUNNING)
|
|
232
|
+
from .device import Device
|
|
233
|
+
from .session import Session
|
|
234
|
+
session = Session(Device(host=a.host))
|
|
235
|
+
try:
|
|
236
|
+
print(f"Opening {a.app} ...")
|
|
237
|
+
t0 = time.time()
|
|
238
|
+
await session.launch(a.app)
|
|
239
|
+
await asyncio.sleep(a.wait)
|
|
240
|
+
text, image = await session.look()
|
|
241
|
+
print(text)
|
|
242
|
+
shot = Path(f"tapwright-smoke-{int(time.time())}.jpg")
|
|
243
|
+
shot.write_bytes(image)
|
|
244
|
+
ok(f"screen read in {time.time() - t0:.1f} s; screenshot {shot}")
|
|
245
|
+
if not a.keep_open:
|
|
246
|
+
await session.terminate(a.app)
|
|
247
|
+
return 0
|
|
248
|
+
finally:
|
|
249
|
+
await session.close()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------- the daemon ----------
|
|
253
|
+
|
|
254
|
+
def stop_daemon() -> int:
|
|
255
|
+
from . import client
|
|
256
|
+
info = store.read_serve_info()
|
|
257
|
+
if not client.healthy(info):
|
|
258
|
+
print("tapwright serve isn't running")
|
|
259
|
+
return 0
|
|
260
|
+
client.shutdown(info)
|
|
261
|
+
deadline = time.monotonic() + 15
|
|
262
|
+
while time.monotonic() < deadline:
|
|
263
|
+
if client.healthy(store.read_serve_info()) is None:
|
|
264
|
+
print("stopped")
|
|
265
|
+
return 0
|
|
266
|
+
time.sleep(0.3)
|
|
267
|
+
print("asked it to stop; it's still running, see ~/.tapwright/serve.log")
|
|
268
|
+
return 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def view() -> int:
|
|
272
|
+
import webbrowser
|
|
273
|
+
|
|
274
|
+
from . import client
|
|
275
|
+
try:
|
|
276
|
+
url = client.viewer_url(client.find_or_start())
|
|
277
|
+
except client.DaemonError as e:
|
|
278
|
+
print(f"couldn't start the live view: {e}")
|
|
279
|
+
return 1
|
|
280
|
+
print(f"Tapwright live view: {url}")
|
|
281
|
+
webbrowser.open(url)
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def skill(a) -> int:
|
|
286
|
+
"""`tapwright skill install`: put the agent skill where Claude Code, Codex and other agents look for skills."""
|
|
287
|
+
from . import skills
|
|
288
|
+
if a.action == "path":
|
|
289
|
+
print(skills.SOURCE)
|
|
290
|
+
return 0
|
|
291
|
+
try:
|
|
292
|
+
written = skills.install(a.project)
|
|
293
|
+
except OSError as e:
|
|
294
|
+
print(f"couldn't install the skill: {e}")
|
|
295
|
+
return 1
|
|
296
|
+
print(skills.installed_text(written))
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main() -> int:
|
|
301
|
+
# pymobiledevice3's test-runner channel logs every message it drops while the runner shuts down as a warning
|
|
302
|
+
# ("received connection termination: <DTXMessage …>", "No channel registered …"): expected, and only noise on a
|
|
303
|
+
# new user's console (seen in a clean install's `tapwright smoke`). Its errors still show.
|
|
304
|
+
logging.getLogger("pymobiledevice3.dtx").setLevel(logging.ERROR)
|
|
305
|
+
ap = argparse.ArgumentParser(prog="tapwright", description=__doc__,
|
|
306
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
307
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
308
|
+
p = sub.add_parser("setup")
|
|
309
|
+
p.add_argument("--udid")
|
|
310
|
+
p.add_argument("--asc-key-id")
|
|
311
|
+
p.add_argument("--asc-issuer-id")
|
|
312
|
+
p.add_argument("--asc-key-file")
|
|
313
|
+
p.add_argument("--bundle-prefix")
|
|
314
|
+
p.add_argument("--p12")
|
|
315
|
+
p.add_argument("--p12-password")
|
|
316
|
+
p.add_argument("--profile")
|
|
317
|
+
sub.add_parser("doctor")
|
|
318
|
+
p = sub.add_parser("smoke")
|
|
319
|
+
p.add_argument("--app", required=True, help="bundle id, e.g. com.example.app")
|
|
320
|
+
p.add_argument("--wait", type=float, default=3.0, help="seconds to let the app start (default 3)")
|
|
321
|
+
p.add_argument("--keep-open", action="store_true")
|
|
322
|
+
p.add_argument("--host")
|
|
323
|
+
p = sub.add_parser("probe")
|
|
324
|
+
p.add_argument("--host", help="an address of the phone to try, e.g. its Tailscale IP")
|
|
325
|
+
p.add_argument("--watch", action="store_true")
|
|
326
|
+
p = sub.add_parser("serve")
|
|
327
|
+
p.add_argument("--port", type=int, help="default 8417; a free port if that one is taken")
|
|
328
|
+
p.add_argument("--phone", help="reach the phone at this address (e.g. its Tailscale IP) instead of USB or Wi-Fi")
|
|
329
|
+
p.add_argument("--keep-alive", action="store_true", help="don't stop after 30 minutes without use")
|
|
330
|
+
p.add_argument("--new-token", action="store_true", help="replace the live view's access token")
|
|
331
|
+
p.add_argument("--stop", action="store_true", help="stop the running daemon")
|
|
332
|
+
p.add_argument("--settle-on-video", action="store_true",
|
|
333
|
+
help="experimental, with --video: actions watch the video's frames and the JPEG stream stays "
|
|
334
|
+
"closed (faster over Wi-Fi; a stalled mirror can make an action answer 'No visible change')")
|
|
335
|
+
p.add_argument("--video", action="store_true",
|
|
336
|
+
help="show the live view as video: start the phone's Screen Mirroring to this computer (same "
|
|
337
|
+
"Wi-Fi; needs UxPlay and `pip install tapwright[video]`)")
|
|
338
|
+
sub.add_parser("view")
|
|
339
|
+
p = sub.add_parser("skill", help="the agent skill: how to test with Tapwright, for Claude Code, Codex and others")
|
|
340
|
+
p.add_argument("action", choices=("install", "path"),
|
|
341
|
+
help="install: copy it to .claude/skills and .agents/skills; path: print where it ships")
|
|
342
|
+
p.add_argument("--project", metavar="DIR", help="install into this project's folder instead of your home folder")
|
|
343
|
+
p = sub.add_parser("mcp")
|
|
344
|
+
p.add_argument("--screenshots", metavar="DIR",
|
|
345
|
+
help="save screenshots in DIR (the newest 50) and answer with their path, instead of sending images")
|
|
346
|
+
scope = p.add_mutually_exclusive_group()
|
|
347
|
+
scope.add_argument("--apps", metavar="APP[,APP]",
|
|
348
|
+
help="the apps the agent may read and touch, as bundle ids or exact names (personal mode, the "
|
|
349
|
+
"default: without this list the agent can do nothing)")
|
|
350
|
+
scope.add_argument("--dedicated", action="store_true",
|
|
351
|
+
help="the phone is kept for testing: the agent may use every app on it")
|
|
352
|
+
a = ap.parse_args()
|
|
353
|
+
if a.cmd == "mcp":
|
|
354
|
+
from . import mcp_server
|
|
355
|
+
apps = [x.strip() for x in (a.apps or "").split(",") if x.strip()]
|
|
356
|
+
mcp_server.run(screenshots=a.screenshots, apps=apps, dedicated=a.dedicated)
|
|
357
|
+
return 0
|
|
358
|
+
if a.cmd == "view":
|
|
359
|
+
return view()
|
|
360
|
+
if a.cmd == "skill":
|
|
361
|
+
return skill(a)
|
|
362
|
+
if a.cmd == "serve" and a.stop:
|
|
363
|
+
return stop_daemon()
|
|
364
|
+
from .device import run
|
|
365
|
+
if a.cmd == "serve":
|
|
366
|
+
from . import server
|
|
367
|
+
port = a.port if a.port is not None else server.DEFAULT_PORT
|
|
368
|
+
return run(lambda: server.serve(port=port, keep_alive=a.keep_alive, new_token=a.new_token, phone=a.phone,
|
|
369
|
+
video=a.video, settle_on_video=a.settle_on_video))
|
|
370
|
+
return run({"setup": setup, "doctor": doctor, "smoke": smoke, "probe": probe}[a.cmd], a)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
sys.exit(main())
|
tapwright/client.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Find the running `tapwright serve`, or start it, and call its API (standard library only)."""
|
|
2
|
+
import http.client
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from . import store
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DaemonError(RuntimeError):
|
|
16
|
+
"""A refusal or error from the daemon, with its code (docs/VIEWER-PROTOCOL.md)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: str, message: str) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code = code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def base_url(info: dict) -> str:
|
|
24
|
+
return f"http://127.0.0.1:{info['port']}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def viewer_url(info: dict) -> str:
|
|
28
|
+
return f"{base_url(info)}/?token={info['token']}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SCOPE: dict[str, str] = {} # the headers that say what this process's agent may use; none: no apps at all
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_scope(apps: list[str] | None = None, dedicated: bool = False) -> None:
|
|
35
|
+
"""Personal mode (the default): the agent may read and touch only `apps` (bundle ids or exact names).
|
|
36
|
+
`dedicated`: the phone is kept for testing, so there is no limit. The daemon enforces it on every call."""
|
|
37
|
+
SCOPE.clear()
|
|
38
|
+
if dedicated:
|
|
39
|
+
SCOPE["X-Tapwright-Mode"] = "dedicated"
|
|
40
|
+
elif apps:
|
|
41
|
+
SCOPE["X-Tapwright-Apps"] = ",".join(apps)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _request(info: dict, method: str, path: str, body: dict | None = None, timeout: float = 10.0,
|
|
45
|
+
client_id: str | None = None) -> dict:
|
|
46
|
+
headers = {"Authorization": f"Bearer {info['token']}", "Content-Type": "application/json"}
|
|
47
|
+
if client_id:
|
|
48
|
+
headers["X-Tapwright-Client"] = client_id
|
|
49
|
+
headers.update(SCOPE) # an agent's calls carry the apps it was given, or that the device is dedicated
|
|
50
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
51
|
+
req = urllib.request.Request(base_url(info) + path, data=data, method=method, headers=headers)
|
|
52
|
+
try:
|
|
53
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
54
|
+
return json.loads(r.read() or b"{}")
|
|
55
|
+
except urllib.error.HTTPError as e:
|
|
56
|
+
try:
|
|
57
|
+
err = json.loads(e.read() or b"{}").get("error") or {}
|
|
58
|
+
except ValueError:
|
|
59
|
+
err = {}
|
|
60
|
+
raise DaemonError(err.get("code", f"http-{e.code}"), err.get("message", f"HTTP {e.code}")) from None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def healthy(info: dict, timeout: float = 2.0) -> dict | None:
|
|
64
|
+
"""The daemon's status if it answers with this token, else None."""
|
|
65
|
+
if not info.get("port") or not info.get("token"):
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
status = _request(info, "GET", "/api/health", timeout=timeout)
|
|
69
|
+
except (OSError, DaemonError, ValueError, http.client.HTTPException):
|
|
70
|
+
return None
|
|
71
|
+
return status if isinstance(status, dict) and "protocol" in status else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def start_daemon() -> subprocess.Popen:
|
|
75
|
+
"""`tapwright serve` in the background: no console window, and it outlives the agent that
|
|
76
|
+
started it (on Windows it leaves the agent host's job object when Windows allows that)."""
|
|
77
|
+
cmd = [sys.executable, "-m", "tapwright", "serve"]
|
|
78
|
+
quiet = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL,
|
|
79
|
+
"close_fds": True}
|
|
80
|
+
if os.name != "nt":
|
|
81
|
+
return subprocess.Popen(cmd, start_new_session=True, **quiet)
|
|
82
|
+
flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
83
|
+
try:
|
|
84
|
+
return subprocess.Popen(cmd, creationflags=flags | subprocess.CREATE_BREAKAWAY_FROM_JOB, **quiet)
|
|
85
|
+
except OSError: # the job doesn't allow breaking away
|
|
86
|
+
return subprocess.Popen(cmd, creationflags=flags, **quiet)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_or_start(wait: float = 60.0) -> dict:
|
|
90
|
+
info = store.read_serve_info()
|
|
91
|
+
if healthy(info):
|
|
92
|
+
return info
|
|
93
|
+
proc = start_daemon()
|
|
94
|
+
deadline = time.monotonic() + wait
|
|
95
|
+
while time.monotonic() < deadline:
|
|
96
|
+
time.sleep(0.5)
|
|
97
|
+
info = store.read_serve_info()
|
|
98
|
+
if healthy(info):
|
|
99
|
+
return info
|
|
100
|
+
if proc is not None and proc.poll() is not None:
|
|
101
|
+
info = store.read_serve_info() # exit code 0 means "already running"
|
|
102
|
+
if healthy(info):
|
|
103
|
+
return info
|
|
104
|
+
raise DaemonError("reconnecting", "tapwright serve exited at start; see ~/.tapwright/serve.log")
|
|
105
|
+
raise DaemonError("reconnecting", "tapwright serve didn't start; see ~/.tapwright/serve.log")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _refused(e: BaseException) -> bool:
|
|
109
|
+
return isinstance(e, ConnectionRefusedError) or (
|
|
110
|
+
isinstance(e, urllib.error.URLError) and isinstance(e.reason, ConnectionRefusedError))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _timed_out(e: BaseException) -> bool:
|
|
114
|
+
return isinstance(e, (TimeoutError, socket.timeout)) or (
|
|
115
|
+
isinstance(e, urllib.error.URLError) and isinstance(e.reason, (TimeoutError, socket.timeout)))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _attempt(info: dict, tool: str, args: dict, timeout: float, client_id: str, retry_ok: bool) -> dict:
|
|
119
|
+
"""One request. Only a refused connection (nothing was sent) is passed up for a retry."""
|
|
120
|
+
try:
|
|
121
|
+
return _request(info, "POST", f"/api/{tool}", args, timeout, client_id)
|
|
122
|
+
except DaemonError:
|
|
123
|
+
raise
|
|
124
|
+
except (OSError, http.client.HTTPException) as e:
|
|
125
|
+
if retry_ok and _refused(e):
|
|
126
|
+
raise
|
|
127
|
+
if _timed_out(e):
|
|
128
|
+
raise DaemonError("timeout", f"no answer from tapwright serve within {timeout:.0f} s; "
|
|
129
|
+
"look at the screen before retrying") from None
|
|
130
|
+
raise DaemonError("reconnecting", str(e)) from None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def call(tool: str, args: dict, client_id: str, timeout: float = 120.0) -> dict:
|
|
134
|
+
"""One agent tool in the daemon: {"text", "image", "image_size"}. Raises DaemonError for refusals.
|
|
135
|
+
It never sends an action twice: it retries only when the connection was refused."""
|
|
136
|
+
info = find_or_start()
|
|
137
|
+
try:
|
|
138
|
+
return _attempt(info, tool, args, timeout, client_id, retry_ok=True)
|
|
139
|
+
except OSError: # refused: the daemon went away between the check and the call
|
|
140
|
+
info = find_or_start()
|
|
141
|
+
return _attempt(info, tool, args, timeout, client_id, retry_ok=False)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def shutdown(info: dict) -> None:
|
|
145
|
+
"""Ask the daemon to stop. It may drop the connection before its answer is out (seen now and then with a
|
|
146
|
+
stream open): that is the stop happening, not a failure, so `tapwright serve --stop` doesn't end in a
|
|
147
|
+
traceback. A refusal (a wrong token) is still raised."""
|
|
148
|
+
try:
|
|
149
|
+
_request(info, "POST", "/api/shutdown", {})
|
|
150
|
+
except (OSError, ValueError, http.client.HTTPException):
|
|
151
|
+
pass
|