simdrive 0.2.0a1__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.
- simdrive/__init__.py +3 -0
- simdrive/_bin/simdrive-input +0 -0
- simdrive/act.py +245 -0
- simdrive/device.py +235 -0
- simdrive/errors.py +142 -0
- simdrive/hid_inject.py +118 -0
- simdrive/observe.py +116 -0
- simdrive/recorder.py +212 -0
- simdrive/server.py +641 -0
- simdrive/session.py +166 -0
- simdrive/sim.py +167 -0
- simdrive/som.py +179 -0
- simdrive/window.py +84 -0
- simdrive-0.2.0a1.dist-info/METADATA +155 -0
- simdrive-0.2.0a1.dist-info/RECORD +18 -0
- simdrive-0.2.0a1.dist-info/WHEEL +5 -0
- simdrive-0.2.0a1.dist-info/entry_points.txt +3 -0
- simdrive-0.2.0a1.dist-info/top_level.txt +1 -0
simdrive/__init__.py
ADDED
|
Binary file
|
simdrive/act.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Synthetic input dispatch.
|
|
2
|
+
|
|
3
|
+
Internal module. Coordinates passed in are screenshot pixels from the most
|
|
4
|
+
recent observe; translated to logical iOS device points using the cached
|
|
5
|
+
scale, then dispatched via the HID-injection backend.
|
|
6
|
+
|
|
7
|
+
Three backends, in preference order:
|
|
8
|
+
1. hid — bundled native helper (real UITouch events; focuses TextFields)
|
|
9
|
+
2. cliclick — synthetic mouse via the macOS window (fallback)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from typing import Iterable, Optional
|
|
17
|
+
|
|
18
|
+
from . import hid_inject, sim
|
|
19
|
+
from .sim import cliclick_path
|
|
20
|
+
from .window import WindowBounds, activate, get_bounds
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ActError(RuntimeError):
|
|
24
|
+
"""Raised when an act dispatch fails."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Cache of UDID → (logical_w, logical_h, scale) from hid_inject.device_size_points()
|
|
28
|
+
_DEVICE_GEOM_CACHE: dict[str, tuple[float, float, float]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _backend() -> str:
|
|
32
|
+
"""Return which backend will be used. Override via SIMDRIVE_INPUT_BACKEND."""
|
|
33
|
+
requested = os.environ.get("SIMDRIVE_INPUT_BACKEND", "").lower()
|
|
34
|
+
if requested == "cliclick":
|
|
35
|
+
return "cliclick"
|
|
36
|
+
if hid_inject.available():
|
|
37
|
+
return "hid"
|
|
38
|
+
return "cliclick"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _device_geom(udid: str) -> tuple[float, float, float]:
|
|
42
|
+
cached = _DEVICE_GEOM_CACHE.get(udid)
|
|
43
|
+
if cached is not None:
|
|
44
|
+
return cached
|
|
45
|
+
geom = hid_inject.device_size_points(udid)
|
|
46
|
+
_DEVICE_GEOM_CACHE[udid] = geom
|
|
47
|
+
return geom
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _pixels_to_points(udid: str, pixel_x: int, pixel_y: int, screenshot_w: int, screenshot_h: int) -> tuple[float, float]:
|
|
51
|
+
"""Map a screenshot pixel coord to logical iOS device points for the HID path."""
|
|
52
|
+
if screenshot_w <= 0 or screenshot_h <= 0:
|
|
53
|
+
raise ActError(f"Invalid screenshot size: {screenshot_w}x{screenshot_h}")
|
|
54
|
+
logical_w, logical_h, _scale = _device_geom(udid)
|
|
55
|
+
px = (pixel_x / screenshot_w) * logical_w
|
|
56
|
+
py = (pixel_y / screenshot_h) * logical_h
|
|
57
|
+
return px, py
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pixels_to_screen(
|
|
61
|
+
bounds: WindowBounds, pixel_x: int, pixel_y: int, screenshot_w: int, screenshot_h: int
|
|
62
|
+
) -> tuple[int, int]:
|
|
63
|
+
if screenshot_w <= 0 or screenshot_h <= 0:
|
|
64
|
+
raise ActError(f"Invalid screenshot size: {screenshot_w}x{screenshot_h}")
|
|
65
|
+
sx = bounds.x + (pixel_x / screenshot_w) * bounds.width
|
|
66
|
+
sy = bounds.y + (pixel_y / screenshot_h) * bounds.height
|
|
67
|
+
return int(round(sx)), int(round(sy))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_cliclick(args: Iterable[str], timeout: float = 5.0) -> None:
|
|
71
|
+
cli = cliclick_path()
|
|
72
|
+
cmd = [cli, *args]
|
|
73
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
|
74
|
+
if res.returncode != 0:
|
|
75
|
+
raise ActError(f"cliclick failed (rc={res.returncode}): {res.stderr.strip() or res.stdout.strip()}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ----------------------------- Public API ------------------------------ #
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def tap(pixel_x: int, pixel_y: int, screenshot_w: int, screenshot_h: int, udid: Optional[str] = None) -> tuple[int, int]:
|
|
82
|
+
"""Click at screenshot-pixel coordinates. Returns the macOS screen coords used (or 0,0 for HID path)."""
|
|
83
|
+
if _backend() == "hid" and udid:
|
|
84
|
+
x_pt, y_pt = _pixels_to_points(udid, pixel_x, pixel_y, screenshot_w, screenshot_h)
|
|
85
|
+
hid_inject.tap(udid, x_pt, y_pt)
|
|
86
|
+
return (0, 0)
|
|
87
|
+
|
|
88
|
+
bounds = get_bounds()
|
|
89
|
+
sx, sy = _pixels_to_screen(bounds, pixel_x, pixel_y, screenshot_w, screenshot_h)
|
|
90
|
+
activate()
|
|
91
|
+
time.sleep(0.15)
|
|
92
|
+
_run_cliclick([f"c:{sx},{sy}"])
|
|
93
|
+
return sx, sy
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def swipe(
|
|
97
|
+
x1: int, y1: int, x2: int, y2: int, screenshot_w: int, screenshot_h: int, duration_ms: int = 300,
|
|
98
|
+
udid: Optional[str] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
if _backend() == "hid" and udid:
|
|
101
|
+
x1p, y1p = _pixels_to_points(udid, x1, y1, screenshot_w, screenshot_h)
|
|
102
|
+
x2p, y2p = _pixels_to_points(udid, x2, y2, screenshot_w, screenshot_h)
|
|
103
|
+
steps = max(4, duration_ms // 25)
|
|
104
|
+
hid_inject.swipe(udid, x1p, y1p, x2p, y2p, steps=steps, step_delay_ms=25)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
bounds = get_bounds()
|
|
108
|
+
sx1, sy1 = _pixels_to_screen(bounds, x1, y1, screenshot_w, screenshot_h)
|
|
109
|
+
sx2, sy2 = _pixels_to_screen(bounds, x2, y2, screenshot_w, screenshot_h)
|
|
110
|
+
activate()
|
|
111
|
+
time.sleep(0.15)
|
|
112
|
+
duration_ms = max(50, min(duration_ms, 5000))
|
|
113
|
+
steps = max(2, duration_ms // 30)
|
|
114
|
+
moves: list[str] = []
|
|
115
|
+
for i in range(1, steps + 1):
|
|
116
|
+
t = i / steps
|
|
117
|
+
mx = int(round(sx1 + (sx2 - sx1) * t))
|
|
118
|
+
my = int(round(sy1 + (sy2 - sy1) * t))
|
|
119
|
+
moves.append(f"m:{mx},{my}")
|
|
120
|
+
args = ["-w", "30", f"dd:{sx1},{sy1}", *moves, f"du:{sx2},{sy2}"]
|
|
121
|
+
_run_cliclick(args, timeout=10.0)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def type_text(text: str, udid: Optional[str] = None) -> None:
|
|
125
|
+
"""Send keystrokes. Caller is responsible for tapping a focused field first.
|
|
126
|
+
|
|
127
|
+
For non-ASCII characters (accented, emoji, non-Latin), falls back to the
|
|
128
|
+
pasteboard path: simctl pbcopy + Cmd-V — preserves the focused-field state
|
|
129
|
+
and works around the HID keyboard's US-ASCII-only key map.
|
|
130
|
+
"""
|
|
131
|
+
if not text:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
is_ascii = all(ord(c) < 128 for c in text)
|
|
135
|
+
|
|
136
|
+
if _backend() == "hid" and udid:
|
|
137
|
+
if is_ascii:
|
|
138
|
+
hid_inject.type_text(udid, text)
|
|
139
|
+
return
|
|
140
|
+
# Non-ASCII path: pbcopy + paste-shortcut
|
|
141
|
+
sim.set_pasteboard(udid, text)
|
|
142
|
+
time.sleep(0.05)
|
|
143
|
+
# Cmd-V via HID — issue Cmd modifier hold + V keypress
|
|
144
|
+
# HID usage 0xE3 = Left Cmd; 0x19 = V
|
|
145
|
+
_hid_paste(udid)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
activate()
|
|
149
|
+
time.sleep(0.15)
|
|
150
|
+
_run_cliclick(["t:" + text])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _hid_paste(udid: str) -> None:
|
|
154
|
+
"""Cmd-V via the HID helper — works in background mode."""
|
|
155
|
+
hid_inject.chord(udid, "cmd", "v")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_CLICLICK_KEY_MAP = {
|
|
159
|
+
"return": "kp:return",
|
|
160
|
+
"enter": "kp:return",
|
|
161
|
+
"tab": "kp:tab",
|
|
162
|
+
"escape": "kp:esc",
|
|
163
|
+
"esc": "kp:esc",
|
|
164
|
+
"space": "kp:space",
|
|
165
|
+
"delete": "kp:delete",
|
|
166
|
+
"backspace": "kp:delete",
|
|
167
|
+
"arrow-up": "kp:arrow-up",
|
|
168
|
+
"arrow-down": "kp:arrow-down",
|
|
169
|
+
"arrow-left": "kp:arrow-left",
|
|
170
|
+
"arrow-right": "kp:arrow-right",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# HID usage codes for special keys (US layout, HID Keyboard/Keypad page)
|
|
174
|
+
_HID_KEY_MAP = {
|
|
175
|
+
"return": 40,
|
|
176
|
+
"enter": 40,
|
|
177
|
+
"tab": 43,
|
|
178
|
+
"escape": 41,
|
|
179
|
+
"esc": 41,
|
|
180
|
+
"space": 44,
|
|
181
|
+
"delete": 42,
|
|
182
|
+
"backspace": 42,
|
|
183
|
+
"arrow-up": 82,
|
|
184
|
+
"arrow-down": 81,
|
|
185
|
+
"arrow-left": 80,
|
|
186
|
+
"arrow-right": 79,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_DEVICE_BUTTONS = {"home", "lock", "siri"} # buttons routed to hid_inject.press_button
|
|
190
|
+
|
|
191
|
+
# Sim-only buttons that go through Simulator's "Device" menu (cliclick fallback path).
|
|
192
|
+
_DEVICE_MENU_KEYS = {
|
|
193
|
+
"home": "Home",
|
|
194
|
+
"lock": "Lock",
|
|
195
|
+
"shake": "Shake",
|
|
196
|
+
"siri": "Siri",
|
|
197
|
+
"app-switcher": "App Switcher",
|
|
198
|
+
"screenshot": "Trigger Screenshot",
|
|
199
|
+
"rotate-left": "Rotate Left",
|
|
200
|
+
"rotate-right": "Rotate Right",
|
|
201
|
+
"action-button": "Action Button",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def press_key(key: str, udid: Optional[str] = None) -> None:
|
|
206
|
+
key_lower = key.lower().strip()
|
|
207
|
+
|
|
208
|
+
if _backend() == "hid" and udid:
|
|
209
|
+
if key_lower in _DEVICE_BUTTONS:
|
|
210
|
+
hid_inject.press_button(udid, key_lower)
|
|
211
|
+
return
|
|
212
|
+
hid_code = _HID_KEY_MAP.get(key_lower)
|
|
213
|
+
if hid_code is not None:
|
|
214
|
+
hid_inject.press_key(udid, hid_code)
|
|
215
|
+
return
|
|
216
|
+
# fall through to cliclick path for unknown keys
|
|
217
|
+
|
|
218
|
+
if key_lower in _DEVICE_MENU_KEYS:
|
|
219
|
+
_menu_click("Device", _DEVICE_MENU_KEYS[key_lower])
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
cli_arg = _CLICLICK_KEY_MAP.get(key_lower)
|
|
223
|
+
if cli_arg is None:
|
|
224
|
+
raise ActError(
|
|
225
|
+
f"unsupported key: {key!r}. Supported: {sorted(_CLICLICK_KEY_MAP)} "
|
|
226
|
+
f"+ {sorted(_DEVICE_MENU_KEYS)}"
|
|
227
|
+
)
|
|
228
|
+
activate()
|
|
229
|
+
time.sleep(0.15)
|
|
230
|
+
_run_cliclick([cli_arg])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _menu_click(menu_title: str, item_title: str) -> None:
|
|
234
|
+
script = f'''
|
|
235
|
+
tell application "System Events"
|
|
236
|
+
tell process "Simulator"
|
|
237
|
+
click menu item "{item_title}" of menu "{menu_title}" of menu bar 1
|
|
238
|
+
end tell
|
|
239
|
+
end tell
|
|
240
|
+
'''
|
|
241
|
+
res = subprocess.run(
|
|
242
|
+
["osascript", "-e", script], capture_output=True, text=True, timeout=5.0, check=False
|
|
243
|
+
)
|
|
244
|
+
if res.returncode != 0:
|
|
245
|
+
raise ActError(f"menu click {menu_title} > {item_title} failed: {res.stderr.strip()}")
|
simdrive/device.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Real-device backend — screenshot + logs + app lifecycle for connected iPhones / iPads.
|
|
2
|
+
|
|
3
|
+
Internal module. Mirrors the surface of ``sim.py`` but for physical devices
|
|
4
|
+
reachable via Apple's ``devicectl`` and libimobiledevice. Touch input is NOT
|
|
5
|
+
implemented here; that's a v0.2.x follow-up that needs WebDriverAgent.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DeviceError(RuntimeError):
|
|
20
|
+
"""Raised when a real-device operation fails."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RealDevice:
|
|
25
|
+
udid: str
|
|
26
|
+
name: str
|
|
27
|
+
model: str
|
|
28
|
+
transport: Optional[str] # "wired" | "localNetwork" | None
|
|
29
|
+
state: str # "available" | "unavailable"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_available(self) -> bool:
|
|
33
|
+
return self.state == "available"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ----------------------- Tool path resolution ----------------------- #
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _which(name: str) -> Optional[str]:
|
|
40
|
+
p = shutil.which(name)
|
|
41
|
+
if p:
|
|
42
|
+
return p
|
|
43
|
+
# Common Homebrew locations
|
|
44
|
+
for cand in (f"/opt/homebrew/bin/{name}", f"/usr/local/bin/{name}"):
|
|
45
|
+
if Path(cand).exists():
|
|
46
|
+
return cand
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def libimobiledevice_available() -> tuple[bool, list[str]]:
|
|
51
|
+
"""Returns (ok, missing_tools)."""
|
|
52
|
+
needed = ["idevice_id", "idevicescreenshot", "idevicesyslog", "ideviceimagemounter"]
|
|
53
|
+
missing = [n for n in needed if not _which(n)]
|
|
54
|
+
return (not missing, missing)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def devicectl_available() -> bool:
|
|
58
|
+
return _which("xcrun") is not None # devicectl ships with Xcode
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ----------------------- Device discovery ----------------------- #
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def list_devices() -> list[RealDevice]:
|
|
65
|
+
"""Enumerate all paired devices (Apple Silicon + libimobiledevice path)."""
|
|
66
|
+
if not devicectl_available():
|
|
67
|
+
raise DeviceError("xcrun (Xcode) not found")
|
|
68
|
+
|
|
69
|
+
import tempfile
|
|
70
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf:
|
|
71
|
+
json_out = tf.name
|
|
72
|
+
try:
|
|
73
|
+
res = subprocess.run(
|
|
74
|
+
["xcrun", "devicectl", "list", "devices",
|
|
75
|
+
"--json-output", json_out, "--quiet"],
|
|
76
|
+
capture_output=True, text=True, timeout=10.0, check=False,
|
|
77
|
+
)
|
|
78
|
+
if res.returncode != 0:
|
|
79
|
+
raise DeviceError(f"devicectl list failed: {res.stderr.strip()}")
|
|
80
|
+
try:
|
|
81
|
+
with open(json_out) as f:
|
|
82
|
+
data = json.load(f)
|
|
83
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
84
|
+
raise DeviceError(f"devicectl list JSON unreadable: {exc}") from exc
|
|
85
|
+
finally:
|
|
86
|
+
try: os.unlink(json_out)
|
|
87
|
+
except OSError: pass
|
|
88
|
+
|
|
89
|
+
out: list[RealDevice] = []
|
|
90
|
+
for d in data.get("result", {}).get("devices", []):
|
|
91
|
+
hw = d.get("hardwareProperties", {}) or {}
|
|
92
|
+
dp = d.get("deviceProperties", {}) or {}
|
|
93
|
+
cp = d.get("connectionProperties", {}) or {}
|
|
94
|
+
out.append(RealDevice(
|
|
95
|
+
udid=hw.get("udid", ""),
|
|
96
|
+
name=dp.get("name", "<unknown>"),
|
|
97
|
+
model=hw.get("marketingName") or hw.get("productType") or "<unknown>",
|
|
98
|
+
transport=cp.get("transportType"),
|
|
99
|
+
state="available" if d.get("connectionProperties", {}).get("transportType") else "unavailable",
|
|
100
|
+
))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find_device(udid: str) -> Optional[RealDevice]:
|
|
105
|
+
for d in list_devices():
|
|
106
|
+
if d.udid == udid or d.udid.replace("-", "") == udid.replace("-", ""):
|
|
107
|
+
return d
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ----------------------- Screenshot ----------------------- #
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def screenshot(udid: str, dest_path: Path) -> Path:
|
|
115
|
+
"""Capture a PNG screenshot to dest_path.
|
|
116
|
+
|
|
117
|
+
`idevicescreenshot` writes TIFF by default; pass an explicit `.png`
|
|
118
|
+
output path and it will encode as PNG.
|
|
119
|
+
"""
|
|
120
|
+
bin_path = _which("idevicescreenshot")
|
|
121
|
+
if not bin_path:
|
|
122
|
+
raise DeviceError(
|
|
123
|
+
"idevicescreenshot not found. Install with: brew install libimobiledevice"
|
|
124
|
+
)
|
|
125
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
res = subprocess.run(
|
|
127
|
+
[bin_path, "-u", udid, str(dest_path)],
|
|
128
|
+
capture_output=True, text=True, timeout=15.0, check=False,
|
|
129
|
+
)
|
|
130
|
+
if res.returncode != 0:
|
|
131
|
+
msg = res.stderr.strip() or res.stdout.strip()
|
|
132
|
+
if "Developer disk image" in msg or "Invalid service" in msg:
|
|
133
|
+
raise DeviceError(
|
|
134
|
+
f"screenshot service unavailable (Developer Disk Image not mounted). "
|
|
135
|
+
f"Run: ideviceimagemounter -u {udid} <path-to-DDI>. Original error: {msg}"
|
|
136
|
+
)
|
|
137
|
+
raise DeviceError(f"idevicescreenshot failed: {msg}")
|
|
138
|
+
if not dest_path.exists():
|
|
139
|
+
raise DeviceError(f"screenshot reported success but file missing: {dest_path}")
|
|
140
|
+
return dest_path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ----------------------- Logs ----------------------- #
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_log_tail(udid: str, lines: int = 50, predicate: Optional[str] = None) -> str:
|
|
147
|
+
"""Capture a short live tail of syslog from the device.
|
|
148
|
+
|
|
149
|
+
`idevicesyslog` streams indefinitely, so we read for ~1 second and trim
|
|
150
|
+
to the most recent `lines` lines. `predicate`, when given, is applied as
|
|
151
|
+
a simple substring filter (NSPredicate on real device requires log
|
|
152
|
+
framework, not idevicesyslog).
|
|
153
|
+
"""
|
|
154
|
+
bin_path = _which("idevicesyslog")
|
|
155
|
+
if not bin_path:
|
|
156
|
+
raise DeviceError(
|
|
157
|
+
"idevicesyslog not found. Install with: brew install libimobiledevice"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
proc = subprocess.Popen(
|
|
161
|
+
[bin_path, "-u", udid],
|
|
162
|
+
stdout=subprocess.PIPE,
|
|
163
|
+
stderr=subprocess.DEVNULL,
|
|
164
|
+
text=True,
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
time.sleep(1.0)
|
|
168
|
+
proc.terminate()
|
|
169
|
+
out, _ = proc.communicate(timeout=2.0)
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
proc.kill()
|
|
172
|
+
out = ""
|
|
173
|
+
|
|
174
|
+
out_lines = (out or "").splitlines()
|
|
175
|
+
if predicate:
|
|
176
|
+
out_lines = [ln for ln in out_lines if predicate in ln]
|
|
177
|
+
return "\n".join(out_lines[-lines:])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ----------------------- App lifecycle ----------------------- #
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def install_app(udid: str, app_path: Path) -> None:
|
|
184
|
+
if not Path(app_path).exists():
|
|
185
|
+
raise DeviceError(f"app bundle not found: {app_path}")
|
|
186
|
+
res = subprocess.run(
|
|
187
|
+
["xcrun", "devicectl", "device", "install", "app",
|
|
188
|
+
"--device", udid, str(app_path)],
|
|
189
|
+
capture_output=True, text=True, timeout=120.0, check=False,
|
|
190
|
+
)
|
|
191
|
+
if res.returncode != 0:
|
|
192
|
+
raise DeviceError(f"devicectl install failed: {res.stderr.strip() or res.stdout.strip()}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def launch_app(udid: str, bundle_id: str) -> int:
|
|
196
|
+
res = subprocess.run(
|
|
197
|
+
["xcrun", "devicectl", "device", "process", "launch",
|
|
198
|
+
"--device", udid, "--start-stopped=false", bundle_id,
|
|
199
|
+
"--json-output", "/dev/stdout"],
|
|
200
|
+
capture_output=True, text=True, timeout=30.0, check=False,
|
|
201
|
+
)
|
|
202
|
+
if res.returncode != 0:
|
|
203
|
+
raise DeviceError(f"devicectl launch failed: {res.stderr.strip() or res.stdout.strip()}")
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(res.stdout)
|
|
206
|
+
return int(data.get("result", {}).get("process", {}).get("processIdentifier", 0))
|
|
207
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def terminate_app(udid: str, bundle_id: str) -> None:
|
|
212
|
+
# devicectl process kill needs PID; we don't track PIDs persistently, so
|
|
213
|
+
# do a best-effort signal via terminate-by-bundle (Xcode 26+ supports this).
|
|
214
|
+
subprocess.run(
|
|
215
|
+
["xcrun", "devicectl", "device", "process", "signal",
|
|
216
|
+
"--device", udid, "--signal", "SIGTERM", "--bundle-id", bundle_id],
|
|
217
|
+
capture_output=True, timeout=10.0, check=False,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ----------------------- Developer Disk Image ----------------------- #
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_developer_disk_mounted(udid: str) -> bool:
|
|
225
|
+
"""Best-effort check via idevicescreenshot — if it succeeds, DDI is mounted."""
|
|
226
|
+
bin_path = _which("idevicescreenshot")
|
|
227
|
+
if not bin_path:
|
|
228
|
+
return False
|
|
229
|
+
# `-l` lists capability without writing; not all idevicescreenshot builds
|
|
230
|
+
# support it. Fall back to a probe: try a quick -h, see if service errors.
|
|
231
|
+
res = subprocess.run(
|
|
232
|
+
[bin_path, "-u", udid, "/dev/null"],
|
|
233
|
+
capture_output=True, text=True, timeout=5.0, check=False,
|
|
234
|
+
)
|
|
235
|
+
return "Invalid service" not in (res.stderr + res.stdout)
|
simdrive/errors.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Structured error model for simdrive.
|
|
2
|
+
|
|
3
|
+
Every error raised by an MCP tool can be caught as a `SimdriveError` and
|
|
4
|
+
inspected via its `.code` (machine-friendly) + `.message` (human-readable).
|
|
5
|
+
The MCP server serializes these to a JSON envelope so agents can switch
|
|
6
|
+
on the code without parsing prose.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SimdriveError(Exception):
|
|
16
|
+
"""Base class for every error simdrive surfaces.
|
|
17
|
+
|
|
18
|
+
Subclass via `.code` rather than the Python class — code strings are the
|
|
19
|
+
stable contract; class hierarchy is implementation-internal.
|
|
20
|
+
"""
|
|
21
|
+
code: str
|
|
22
|
+
message: str
|
|
23
|
+
details: dict = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
return f"[{self.code}] {self.message}"
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
return {"ok": False, "error": {"code": self.code, "message": self.message, "details": self.details}}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --------- Standard error codes (the agent contract) --------- #
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def no_session(session_id: str) -> SimdriveError:
|
|
36
|
+
return SimdriveError(
|
|
37
|
+
code="no_session",
|
|
38
|
+
message=f"unknown session_id {session_id!r}. Call session_start first.",
|
|
39
|
+
details={"session_id": session_id},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def no_device(query: dict) -> SimdriveError:
|
|
44
|
+
return SimdriveError(
|
|
45
|
+
code="no_device",
|
|
46
|
+
message=f"no booted simulator matched {query}. Pass `device` to boot one.",
|
|
47
|
+
details={"query": query},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sim_unhealthy(udid: str, reason: str) -> SimdriveError:
|
|
52
|
+
return SimdriveError(
|
|
53
|
+
code="sim_unhealthy",
|
|
54
|
+
message=(
|
|
55
|
+
f"simulator {udid} is in a degraded state ({reason}). "
|
|
56
|
+
"Recovery: quit Simulator.app and `xcrun simctl shutdown all && xcrun simctl boot {udid}`."
|
|
57
|
+
),
|
|
58
|
+
details={"udid": udid, "reason": reason},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def hid_unavailable(reason: str) -> SimdriveError:
|
|
63
|
+
return SimdriveError(
|
|
64
|
+
code="hid_unavailable",
|
|
65
|
+
message=(
|
|
66
|
+
f"native HID helper unavailable: {reason}. "
|
|
67
|
+
"Reinstall simdrive (the bundled binary is required) or `cd simdrive/native && make`."
|
|
68
|
+
),
|
|
69
|
+
details={"reason": reason},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def target_not_found(form: str, query: Any, available: Optional[list] = None) -> SimdriveError:
|
|
74
|
+
return SimdriveError(
|
|
75
|
+
code="target_not_found",
|
|
76
|
+
message=(
|
|
77
|
+
f"no {form} match for {query!r} in last observe. "
|
|
78
|
+
f"Available: {available[:30] if available else '(none)'}"
|
|
79
|
+
),
|
|
80
|
+
details={"form": form, "query": query, "available": available},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def missing_target() -> SimdriveError:
|
|
85
|
+
return SimdriveError(
|
|
86
|
+
code="missing_target",
|
|
87
|
+
message="tap target required: provide {x, y}, {mark: <id>}, or {text: <query>}",
|
|
88
|
+
details={},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def invalid_argument(field: str, value: Any, why: str) -> SimdriveError:
|
|
93
|
+
return SimdriveError(
|
|
94
|
+
code="invalid_argument",
|
|
95
|
+
message=f"invalid {field}={value!r}: {why}",
|
|
96
|
+
details={"field": field, "value": value, "why": why},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def already_recording(session_id: str, name: str) -> SimdriveError:
|
|
101
|
+
return SimdriveError(
|
|
102
|
+
code="already_recording",
|
|
103
|
+
message=f"session {session_id} already recording {name!r}; call record_stop first.",
|
|
104
|
+
details={"session_id": session_id, "name": name},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def not_recording(session_id: str) -> SimdriveError:
|
|
109
|
+
return SimdriveError(
|
|
110
|
+
code="not_recording",
|
|
111
|
+
message=f"session {session_id} is not recording.",
|
|
112
|
+
details={"session_id": session_id},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def recording_not_found(name: str, path: str) -> SimdriveError:
|
|
117
|
+
return SimdriveError(
|
|
118
|
+
code="recording_not_found",
|
|
119
|
+
message=f"recording {name!r} not found at {path}",
|
|
120
|
+
details={"name": name, "path": path},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def device_input_unavailable(action: str) -> SimdriveError:
|
|
125
|
+
return SimdriveError(
|
|
126
|
+
code="device_input_unavailable",
|
|
127
|
+
message=(
|
|
128
|
+
f"'{action}' on a real device is not yet supported. simdrive v0.1.x "
|
|
129
|
+
"drives observe + logs + app lifecycle on real devices, but synthetic "
|
|
130
|
+
"touch/keyboard input requires WebDriverAgent. Coming in v0.2; track "
|
|
131
|
+
"in docs/REAL_DEVICE_FEASIBILITY.md."
|
|
132
|
+
),
|
|
133
|
+
details={"action": action},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def replay_drift_halt(step_id: int, similarity: float, threshold: float) -> SimdriveError:
|
|
138
|
+
return SimdriveError(
|
|
139
|
+
code="replay_drift_halt",
|
|
140
|
+
message=f"replay halted at step {step_id}: similarity {similarity:.3f} below threshold {threshold:.3f}",
|
|
141
|
+
details={"step_id": step_id, "similarity": similarity, "threshold": threshold},
|
|
142
|
+
)
|