use-computer 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mmini/recording.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from mmini.models import RecordingInfo
6
+
7
+
8
+ class Recording:
9
+ def __init__(self, http: httpx.Client, prefix: str):
10
+ self._http = http
11
+ self._prefix = prefix
12
+
13
+ def start(self, name: str | None = None) -> RecordingInfo:
14
+ payload: dict = {}
15
+ if name:
16
+ payload["name"] = name
17
+ resp = self._http.post(f"{self._prefix}/recording/start", json=payload)
18
+ resp.raise_for_status()
19
+ return RecordingInfo.from_dict(resp.json())
20
+
21
+ def stop(self, recording_id: str) -> RecordingInfo:
22
+ resp = self._http.post(f"{self._prefix}/recording/stop", json={"id": recording_id})
23
+ resp.raise_for_status()
24
+ return RecordingInfo.from_dict(resp.json())
25
+
26
+ def list_all(self) -> list[RecordingInfo]:
27
+ resp = self._http.get(f"{self._prefix}/recordings")
28
+ resp.raise_for_status()
29
+ return [RecordingInfo.from_dict(r) for r in resp.json()]
30
+
31
+ def get(self, recording_id: str) -> RecordingInfo:
32
+ resp = self._http.get(f"{self._prefix}/recordings/{recording_id}")
33
+ resp.raise_for_status()
34
+ return RecordingInfo.from_dict(resp.json())
35
+
36
+ def download(self, recording_id: str, local_path: str, timeout: float = 30.0) -> None:
37
+ download_url = f"{self._prefix}/recordings/{recording_id}/download"
38
+ with self._http.stream("GET", download_url, timeout=timeout) as resp:
39
+ resp.raise_for_status()
40
+ with open(local_path, "wb") as f:
41
+ for chunk in resp.iter_bytes(chunk_size=65536):
42
+ f.write(chunk)
43
+
44
+ def delete(self, recording_id: str) -> None:
45
+ resp = self._http.delete(f"{self._prefix}/recordings/{recording_id}")
46
+ resp.raise_for_status()
47
+
48
+
49
+ class AsyncRecording:
50
+ def __init__(self, http: httpx.AsyncClient, prefix: str):
51
+ self._http = http
52
+ self._prefix = prefix
53
+
54
+ async def start(self, name: str | None = None) -> RecordingInfo:
55
+ payload: dict = {}
56
+ if name:
57
+ payload["name"] = name
58
+ resp = await self._http.post(f"{self._prefix}/recording/start", json=payload)
59
+ resp.raise_for_status()
60
+ return RecordingInfo.from_dict(resp.json())
61
+
62
+ async def stop(self, recording_id: str) -> RecordingInfo:
63
+ resp = await self._http.post(f"{self._prefix}/recording/stop", json={"id": recording_id})
64
+ resp.raise_for_status()
65
+ return RecordingInfo.from_dict(resp.json())
66
+
67
+ async def list_all(self) -> list[RecordingInfo]:
68
+ resp = await self._http.get(f"{self._prefix}/recordings")
69
+ resp.raise_for_status()
70
+ return [RecordingInfo.from_dict(r) for r in resp.json()]
71
+
72
+ async def get(self, recording_id: str) -> RecordingInfo:
73
+ resp = await self._http.get(f"{self._prefix}/recordings/{recording_id}")
74
+ resp.raise_for_status()
75
+ return RecordingInfo.from_dict(resp.json())
76
+
77
+ async def download(self, recording_id: str, local_path: str, timeout: float = 30.0) -> None:
78
+ async with self._http.stream(
79
+ "GET", f"{self._prefix}/recordings/{recording_id}/download", timeout=timeout
80
+ ) as resp:
81
+ resp.raise_for_status()
82
+ with open(local_path, "wb") as f:
83
+ async for chunk in resp.aiter_bytes(chunk_size=65536):
84
+ f.write(chunk)
85
+
86
+ async def delete(self, recording_id: str) -> None:
87
+ resp = await self._http.delete(f"{self._prefix}/recordings/{recording_id}")
88
+ resp.raise_for_status()
mmini/retry.py ADDED
@@ -0,0 +1,135 @@
1
+ """Single retry layer for all mmini SDK HTTP calls.
2
+
3
+ Retries are handled at the httpx transport level so they apply
4
+ automatically to every request — no per-method wrappers needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import time
12
+
13
+ import httpx
14
+
15
+ _log = logging.getLogger("mmini.retry")
16
+
17
+ RETRYABLE_EXCEPTIONS = (
18
+ httpx.TimeoutException,
19
+ httpx.ConnectError,
20
+ httpx.ReadError,
21
+ httpx.WriteError,
22
+ )
23
+ RETRYABLE_STATUS_CODES = frozenset({500, 502, 503, 504, 529})
24
+ NO_RETRY_PATTERNS = ("not found", "connection refused")
25
+ MAX_RETRIES = 3
26
+ RETRY_DELAY = 2.0
27
+
28
+
29
+ def _is_retryable_body(body: str) -> bool:
30
+ lower = body.lower()
31
+ return not any(p in lower for p in NO_RETRY_PATTERNS)
32
+
33
+
34
+ class RetryTransport(httpx.BaseTransport):
35
+ def __init__(self, transport: httpx.BaseTransport):
36
+ self._transport = transport
37
+
38
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
39
+ resp = None
40
+ for attempt in range(MAX_RETRIES + 1):
41
+ try:
42
+ resp = self._transport.handle_request(request)
43
+ except RETRYABLE_EXCEPTIONS as exc:
44
+ if attempt == MAX_RETRIES:
45
+ raise
46
+ _log.warning(
47
+ "retry %d/%d: %s %s → %s",
48
+ attempt + 1,
49
+ MAX_RETRIES,
50
+ request.method,
51
+ request.url,
52
+ type(exc).__name__,
53
+ )
54
+ time.sleep(RETRY_DELAY)
55
+ continue
56
+
57
+ if resp.status_code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
58
+ return resp
59
+
60
+ body = ""
61
+ try:
62
+ resp.read()
63
+ body = resp.text[:500]
64
+ except Exception:
65
+ pass
66
+ if not _is_retryable_body(body):
67
+ return resp
68
+
69
+ _log.warning(
70
+ "retry %d/%d: %s %s → %d %s",
71
+ attempt + 1,
72
+ MAX_RETRIES,
73
+ request.method,
74
+ request.url,
75
+ resp.status_code,
76
+ body,
77
+ )
78
+ resp.close()
79
+ time.sleep(RETRY_DELAY)
80
+ raise RuntimeError("retry transport exhausted without a response")
81
+
82
+ def close(self) -> None:
83
+ self._transport.close()
84
+
85
+
86
+ class AsyncRetryTransport(httpx.AsyncBaseTransport):
87
+ def __init__(self, transport: httpx.AsyncBaseTransport):
88
+ self._transport = transport
89
+
90
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
91
+ resp = None
92
+ for attempt in range(MAX_RETRIES + 1):
93
+ try:
94
+ resp = await self._transport.handle_async_request(request)
95
+ except RETRYABLE_EXCEPTIONS as exc:
96
+ if attempt == MAX_RETRIES:
97
+ raise
98
+ _log.warning(
99
+ "retry %d/%d: %s %s → %s",
100
+ attempt + 1,
101
+ MAX_RETRIES,
102
+ request.method,
103
+ request.url,
104
+ type(exc).__name__,
105
+ )
106
+ await asyncio.sleep(RETRY_DELAY)
107
+ continue
108
+
109
+ if resp.status_code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
110
+ return resp
111
+
112
+ body = ""
113
+ try:
114
+ await resp.aread()
115
+ body = resp.text[:500]
116
+ except Exception:
117
+ pass
118
+ if not _is_retryable_body(body):
119
+ return resp
120
+
121
+ _log.warning(
122
+ "retry %d/%d: %s %s → %d %s",
123
+ attempt + 1,
124
+ MAX_RETRIES,
125
+ request.method,
126
+ request.url,
127
+ resp.status_code,
128
+ body,
129
+ )
130
+ await resp.aclose()
131
+ await asyncio.sleep(RETRY_DELAY)
132
+ raise RuntimeError("async retry transport exhausted without a response")
133
+
134
+ async def aclose(self) -> None:
135
+ await self._transport.aclose()
mmini/sandbox.py ADDED
@@ -0,0 +1,419 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import tarfile
5
+ import threading
6
+ import urllib.request
7
+ from enum import Enum
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ from mmini.display import AsyncDisplay, Display
13
+ from mmini.ios.apps import Apps, AsyncApps
14
+ from mmini.ios.environment import AsyncEnvironment, Environment
15
+ from mmini.ios.input import AsyncInput, Input
16
+ from mmini.macos.keyboard import AsyncKeyboard, Keyboard
17
+ from mmini.macos.mouse import AsyncMouse, Mouse
18
+ from mmini.models import ActResult, ExecResult
19
+ from mmini.recording import AsyncRecording, Recording
20
+ from mmini.screenshot import AsyncScreenshot, Screenshot
21
+
22
+
23
+ class SandboxType(str, Enum):
24
+ MACOS = "macos"
25
+ IOS = "ios"
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Keepalive helper — shared by sync and async sandboxes
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def _start_keepalive_thread(
34
+ http_client: httpx.Client | httpx.AsyncClient,
35
+ prefix: str,
36
+ interval: float,
37
+ ) -> tuple[threading.Event, threading.Thread]:
38
+ """Spawn a daemon thread that POSTs `<prefix>/keepalive` every `interval` seconds.
39
+
40
+ Uses urllib.request directly (not the SDK's httpx client) so the thread
41
+ runs independently of any asyncio loop or shared connection pool. This is
42
+ why it works for both sync Sandbox and AsyncSandbox: the thread never
43
+ touches the caller's HTTP client.
44
+ """
45
+ base_url = str(http_client.base_url).rstrip("/")
46
+ keepalive_url = f"{base_url}{prefix}/keepalive"
47
+ auth = http_client.headers.get("Authorization", "")
48
+ headers = {"Authorization": auth} if auth else {}
49
+
50
+ stop = threading.Event()
51
+
52
+ def _loop() -> None:
53
+ while not stop.is_set():
54
+ stop.wait(interval)
55
+ if stop.is_set():
56
+ break
57
+ try:
58
+ req = urllib.request.Request(keepalive_url, method="POST", headers=headers)
59
+ urllib.request.urlopen(req, timeout=10).close()
60
+ except Exception:
61
+ pass
62
+
63
+ thread = threading.Thread(target=_loop, daemon=True)
64
+ thread.start()
65
+ return stop, thread
66
+
67
+
68
+ def _stop_keepalive_thread(
69
+ stop: threading.Event | None,
70
+ thread: threading.Thread | None,
71
+ ) -> None:
72
+ if stop:
73
+ stop.set()
74
+ if thread:
75
+ thread.join(timeout=2)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Sync
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class Sandbox:
84
+ """Base sandbox — shared operations that work on both macOS and iOS."""
85
+
86
+ def __init__(
87
+ self,
88
+ sandbox_id: str,
89
+ type: SandboxType,
90
+ http: httpx.Client,
91
+ *,
92
+ vnc_url: str = "",
93
+ ssh_url: str = "",
94
+ ):
95
+ self.sandbox_id = sandbox_id
96
+ self.type = type
97
+ self.vnc_url = vnc_url
98
+ self.ssh_url = ssh_url
99
+ self._http = http
100
+ self._prefix = f"/v1/sandboxes/{sandbox_id}"
101
+ self._keepalive_stop: threading.Event | None = None
102
+ self._keepalive_thread: threading.Thread | None = None
103
+
104
+ self.screenshot = Screenshot(http, self._prefix)
105
+ self.recording = Recording(http, self._prefix)
106
+ self.display = Display(http, self._prefix)
107
+
108
+ def upload(self, local_path: str | Path, remote_path: str) -> None:
109
+ with open(local_path, "rb") as f:
110
+ data = f.read()
111
+ self.upload_bytes(data, remote_path)
112
+
113
+ def upload_bytes(self, data: bytes, remote_path: str) -> None:
114
+ # NOTE: parent directories must exist — SCP won't create them.
115
+ # Use upload_dir() for nested paths or mkdir -p via exec_ssh first.
116
+ resp = self._http.put(
117
+ f"{self._prefix}/files",
118
+ params={"path": remote_path},
119
+ content=data,
120
+ headers={"Content-Type": "application/octet-stream"},
121
+ )
122
+ resp.raise_for_status()
123
+
124
+ def download_file(self, remote_path: str, local_path: str | Path) -> None:
125
+ local_path = Path(local_path)
126
+ local_path.parent.mkdir(parents=True, exist_ok=True)
127
+ resp = self._http.get(f"{self._prefix}/files", params={"path": remote_path})
128
+ resp.raise_for_status()
129
+ local_path.write_bytes(resp.content)
130
+
131
+ def start_keepalive(self, interval: float = 30.0) -> None:
132
+ """Start a background thread that pings the gateway every `interval` seconds."""
133
+ self._keepalive_stop, self._keepalive_thread = _start_keepalive_thread(
134
+ self._http, self._prefix, interval
135
+ )
136
+
137
+ def stop_keepalive(self) -> None:
138
+ """Stop the keepalive background thread."""
139
+ _stop_keepalive_thread(self._keepalive_stop, self._keepalive_thread)
140
+ self._keepalive_stop = None
141
+ self._keepalive_thread = None
142
+
143
+ def close(self):
144
+ self.stop_keepalive()
145
+ self._http.delete(f"{self._prefix}")
146
+
147
+ def __enter__(self):
148
+ return self
149
+
150
+ def __exit__(self, *_):
151
+ self.close()
152
+
153
+ def __repr__(self):
154
+ return f"Sandbox({self.sandbox_id!r}, type={self.type.value!r})"
155
+
156
+
157
+ class MacOSSandbox(Sandbox):
158
+ """macOS VM sandbox — full mouse, keyboard, SSH exec, VNC."""
159
+
160
+ def __init__(
161
+ self,
162
+ sandbox_id: str,
163
+ http: httpx.Client,
164
+ *,
165
+ vnc_url: str = "",
166
+ ssh_url: str = "",
167
+ vm_ip: str = "",
168
+ host: str = "",
169
+ ):
170
+ super().__init__(sandbox_id, SandboxType.MACOS, http, vnc_url=vnc_url, ssh_url=ssh_url)
171
+ self.vm_ip = vm_ip
172
+ self.host = host
173
+ self.mouse = Mouse(http, self._prefix)
174
+ self.keyboard = Keyboard(http, self._prefix)
175
+
176
+ def act(
177
+ self,
178
+ action: dict,
179
+ screenshot_after: bool = True,
180
+ screenshot_delay_ms: int = 100,
181
+ ) -> ActResult:
182
+ resp = self._http.post(
183
+ f"{self._prefix}/act",
184
+ json={
185
+ "action": action,
186
+ "screenshot_after": screenshot_after,
187
+ "screenshot_delay_ms": screenshot_delay_ms,
188
+ },
189
+ )
190
+ resp.raise_for_status()
191
+ if screenshot_after and resp.headers.get("content-type", "").startswith("image/"):
192
+ return ActResult(screenshot=resp.content)
193
+ return ActResult(data=resp.json())
194
+
195
+ def exec_ssh(self, command: str, timeout: int = 120) -> ExecResult:
196
+ resp = self._http.post(f"{self._prefix}/exec", json={"command": command}, timeout=timeout)
197
+ resp.raise_for_status()
198
+ return ExecResult.from_dict(resp.json())
199
+
200
+ def exec_ax(self, command: str, timeout: int = 120) -> ExecResult:
201
+ """Run a shell command on the VM via cua-server's run_command.
202
+
203
+ Use this *only* when your command needs the macOS Accessibility API
204
+ (AXUIElementCopyAttributeValue, synthetic CGEvent, etc.). The
205
+ responsibility chain through cua-server (`launchd → cua-server →
206
+ bash → command`) lets the system TCC grant on python3.12 actually
207
+ apply, while the SSH-backed `exec_ssh` puts `sshd-keygen-wrapper`
208
+ in the chain and TCC denies AX calls with -25211.
209
+
210
+ Sparser env than `exec_ssh` — set $PATH explicitly inside your
211
+ command if you need brew binaries.
212
+ """
213
+ resp = self._http.post(
214
+ f"{self._prefix}/exec_ax", json={"command": command}, timeout=timeout
215
+ )
216
+ resp.raise_for_status()
217
+ return ExecResult.from_dict(resp.json())
218
+
219
+ def upload_dir(self, local_dir: str | Path, remote_dir: str) -> None:
220
+ local_dir = Path(local_dir)
221
+ buf = io.BytesIO()
222
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
223
+ for item in local_dir.rglob("*"):
224
+ tar.add(str(item), arcname=str(item.relative_to(local_dir)))
225
+ tmp = f"/tmp/_mmini_upload_{self.sandbox_id[-8:]}.tar.gz"
226
+ self.upload_bytes(buf.getvalue(), tmp)
227
+ self.exec_ssh(f'mkdir -p "{remote_dir}" && tar xzf {tmp} -C "{remote_dir}" && rm -f {tmp}')
228
+
229
+ def download_dir(self, remote_dir: str, local_dir: str | Path) -> None:
230
+ local_dir = Path(local_dir)
231
+ local_dir.mkdir(parents=True, exist_ok=True)
232
+ # List files via exec, then download each
233
+ result = self.exec_ssh(f'find "{remote_dir}" -type f')
234
+ for line in result.stdout.strip().splitlines():
235
+ line = line.strip()
236
+ if not line:
237
+ continue
238
+ rel = line[len(remote_dir) :].lstrip("/")
239
+ local_path = local_dir / rel
240
+ self.download_file(line, local_path)
241
+
242
+
243
+ class IOSSandbox(Sandbox):
244
+ """iOS simulator sandbox — tap, swipe, hardware buttons, app management."""
245
+
246
+ def __init__(self, sandbox_id: str, http: httpx.Client):
247
+ super().__init__(sandbox_id, SandboxType.IOS, http)
248
+ self.input = Input(http, self._prefix)
249
+ self.apps = Apps(http, self._prefix)
250
+ self.environment = Environment(http, self._prefix)
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # Async
255
+ # ---------------------------------------------------------------------------
256
+
257
+
258
+ class AsyncSandbox:
259
+ """Base async sandbox — shared operations that work on both macOS and iOS."""
260
+
261
+ def __init__(
262
+ self,
263
+ sandbox_id: str,
264
+ type: SandboxType,
265
+ http: httpx.AsyncClient,
266
+ *,
267
+ vnc_url: str = "",
268
+ ssh_url: str = "",
269
+ ):
270
+ self.sandbox_id = sandbox_id
271
+ self.type = type
272
+ self.vnc_url = vnc_url
273
+ self.ssh_url = ssh_url
274
+ self._http = http
275
+ self._prefix = f"/v1/sandboxes/{sandbox_id}"
276
+ self._keepalive_stop: threading.Event | None = None
277
+ self._keepalive_thread: threading.Thread | None = None
278
+
279
+ self.screenshot = AsyncScreenshot(http, self._prefix)
280
+ self.recording = AsyncRecording(http, self._prefix)
281
+ self.display = AsyncDisplay(http, self._prefix)
282
+
283
+ async def upload(self, local_path: str | Path, remote_path: str) -> None:
284
+ with open(local_path, "rb") as f:
285
+ data = f.read()
286
+ await self.upload_bytes(data, remote_path)
287
+
288
+ async def upload_bytes(self, data: bytes, remote_path: str) -> None:
289
+ resp = await self._http.put(
290
+ f"{self._prefix}/files",
291
+ params={"path": remote_path},
292
+ content=data,
293
+ headers={"Content-Type": "application/octet-stream"},
294
+ )
295
+ resp.raise_for_status()
296
+
297
+ async def download_file(self, remote_path: str, local_path: str | Path) -> None:
298
+ local_path = Path(local_path)
299
+ local_path.parent.mkdir(parents=True, exist_ok=True)
300
+ resp = await self._http.get(f"{self._prefix}/files", params={"path": remote_path})
301
+ resp.raise_for_status()
302
+ local_path.write_bytes(resp.content)
303
+
304
+ async def start_keepalive(self, interval: float = 30.0) -> None:
305
+ """Start a background OS thread that pings the gateway every `interval` seconds.
306
+
307
+ Uses a real thread (not an asyncio task) so it fires reliably even when
308
+ the event loop is blocked by sync calls (e.g. sync Anthropic client).
309
+ """
310
+ self._keepalive_stop, self._keepalive_thread = _start_keepalive_thread(
311
+ self._http, self._prefix, interval
312
+ )
313
+
314
+ async def stop_keepalive(self) -> None:
315
+ """Stop the keepalive background thread."""
316
+ _stop_keepalive_thread(self._keepalive_stop, self._keepalive_thread)
317
+ self._keepalive_stop = None
318
+ self._keepalive_thread = None
319
+
320
+ async def close(self):
321
+ await self.stop_keepalive()
322
+ await self._http.delete(f"{self._prefix}")
323
+
324
+ async def __aenter__(self):
325
+ return self
326
+
327
+ async def __aexit__(self, *_):
328
+ await self.close()
329
+
330
+ def __repr__(self):
331
+ return f"AsyncSandbox({self.sandbox_id!r}, type={self.type.value!r})"
332
+
333
+
334
+ class AsyncMacOSSandbox(AsyncSandbox):
335
+ """Async macOS VM sandbox."""
336
+
337
+ def __init__(
338
+ self,
339
+ sandbox_id: str,
340
+ http: httpx.AsyncClient,
341
+ *,
342
+ vnc_url: str = "",
343
+ ssh_url: str = "",
344
+ vm_ip: str = "",
345
+ host: str = "",
346
+ ):
347
+ super().__init__(sandbox_id, SandboxType.MACOS, http, vnc_url=vnc_url, ssh_url=ssh_url)
348
+ self.vm_ip = vm_ip
349
+ self.host = host
350
+ self.mouse = AsyncMouse(http, self._prefix)
351
+ self.keyboard = AsyncKeyboard(http, self._prefix)
352
+
353
+ async def act(
354
+ self,
355
+ action: dict,
356
+ screenshot_after: bool = True,
357
+ screenshot_delay_ms: int = 100,
358
+ ) -> ActResult:
359
+ resp = await self._http.post(
360
+ f"{self._prefix}/act",
361
+ json={
362
+ "action": action,
363
+ "screenshot_after": screenshot_after,
364
+ "screenshot_delay_ms": screenshot_delay_ms,
365
+ },
366
+ )
367
+ resp.raise_for_status()
368
+ if screenshot_after and resp.headers.get("content-type", "").startswith("image/"):
369
+ return ActResult(screenshot=resp.content)
370
+ return ActResult(data=resp.json())
371
+
372
+ async def exec_ssh(self, command: str, timeout: int = 120) -> ExecResult:
373
+ resp = await self._http.post(
374
+ f"{self._prefix}/exec", json={"command": command}, timeout=timeout
375
+ )
376
+ resp.raise_for_status()
377
+ return ExecResult.from_dict(resp.json())
378
+
379
+ async def exec_ax(self, command: str, timeout: int = 120) -> ExecResult:
380
+ """Async version of exec_ax — see MacOSSandbox.exec_ax for details."""
381
+ resp = await self._http.post(
382
+ f"{self._prefix}/exec_ax", json={"command": command}, timeout=timeout
383
+ )
384
+ resp.raise_for_status()
385
+ return ExecResult.from_dict(resp.json())
386
+
387
+ async def upload_dir(self, local_dir: str | Path, remote_dir: str) -> None:
388
+ local_dir = Path(local_dir)
389
+ buf = io.BytesIO()
390
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
391
+ for item in local_dir.rglob("*"):
392
+ tar.add(str(item), arcname=str(item.relative_to(local_dir)))
393
+ tmp = f"/tmp/_mmini_upload_{self.sandbox_id[-8:]}.tar.gz"
394
+ await self.upload_bytes(buf.getvalue(), tmp)
395
+ cmd = f'mkdir -p "{remote_dir}" && tar xzf {tmp} -C "{remote_dir}" && rm -f {tmp}'
396
+ await self.exec_ssh(cmd)
397
+
398
+ async def download_dir(self, remote_dir: str, local_dir: str | Path) -> None:
399
+ local_dir = Path(local_dir)
400
+ local_dir.mkdir(parents=True, exist_ok=True)
401
+ # List files via exec, then download each
402
+ result = await self.exec_ssh(f'find "{remote_dir}" -type f')
403
+ for line in result.stdout.strip().splitlines():
404
+ line = line.strip()
405
+ if not line:
406
+ continue
407
+ rel = line[len(remote_dir) :].lstrip("/")
408
+ local_path = local_dir / rel
409
+ await self.download_file(line, local_path)
410
+
411
+
412
+ class AsyncIOSSandbox(AsyncSandbox):
413
+ """Async iOS simulator sandbox."""
414
+
415
+ def __init__(self, sandbox_id: str, http: httpx.AsyncClient):
416
+ super().__init__(sandbox_id, SandboxType.IOS, http)
417
+ self.input = AsyncInput(http, self._prefix)
418
+ self.apps = AsyncApps(http, self._prefix)
419
+ self.environment = AsyncEnvironment(http, self._prefix)