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 ADDED
@@ -0,0 +1,3 @@
1
+ """simdrive — hand your iOS simulator to your agent."""
2
+
3
+ __version__ = "0.2.0a1"
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
+ )