@qubiqlabs/mobiflow 0.9.1 → 1.0.0

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.
Files changed (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +73 -28
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. package/src/mobiflow/suite.py +263 -0
@@ -0,0 +1,717 @@
1
+ """Discover and auto-start Android emulators / iOS simulators (macOS + Windows).
2
+
3
+ - Android: ``adb`` online devices + ``emulator -list-avds``; start via emulator binary
4
+ - iOS: ``xcrun simctl`` (macOS only); boot + open Simulator.app
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ import platform
13
+ import re
14
+ import shutil
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any, Callable, Optional
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ ProgressFn = Optional[Callable[[str], None]]
22
+ IS_MAC = platform.system() == "Darwin"
23
+ IS_WIN = platform.system() == "Windows"
24
+
25
+
26
+ def _sdk_roots() -> list[Path]:
27
+ roots: list[Path] = []
28
+ for key in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
29
+ val = os.environ.get(key)
30
+ if val:
31
+ roots.append(Path(val))
32
+ home = Path.home()
33
+ if IS_MAC:
34
+ roots.append(home / "Library/Android/sdk")
35
+ if IS_WIN:
36
+ local = os.environ.get("LOCALAPPDATA") or str(home / "AppData/Local")
37
+ roots.append(Path(local) / "Android" / "Sdk")
38
+ roots.append(home / "AppData/Local/Android/Sdk")
39
+ # Linux common
40
+ roots.append(home / "Android/Sdk")
41
+ # Dedupe
42
+ seen: set[str] = set()
43
+ out: list[Path] = []
44
+ for r in roots:
45
+ key = str(r.resolve()) if r.exists() else str(r)
46
+ if key not in seen:
47
+ seen.add(key)
48
+ out.append(r)
49
+ return out
50
+
51
+
52
+ def resolve_adb() -> Optional[str]:
53
+ which = shutil.which("adb")
54
+ if which:
55
+ return which
56
+ exe = "adb.exe" if IS_WIN else "adb"
57
+ for root in _sdk_roots():
58
+ candidate = root / "platform-tools" / exe
59
+ if candidate.is_file():
60
+ return str(candidate)
61
+ return None
62
+
63
+
64
+ def resolve_emulator() -> Optional[str]:
65
+ which = shutil.which("emulator")
66
+ if which:
67
+ return which
68
+ exe = "emulator.exe" if IS_WIN else "emulator"
69
+ for root in _sdk_roots():
70
+ for sub in ("emulator", "tools"):
71
+ candidate = root / sub / exe
72
+ if candidate.is_file():
73
+ return str(candidate)
74
+ return None
75
+
76
+
77
+ async def _run_cmd(
78
+ args: list[str],
79
+ *,
80
+ timeout: float = 60.0,
81
+ cwd: Optional[str] = None,
82
+ ) -> dict[str, Any]:
83
+ try:
84
+ proc = await asyncio.create_subprocess_exec(
85
+ *args,
86
+ stdout=asyncio.subprocess.PIPE,
87
+ stderr=asyncio.subprocess.PIPE,
88
+ cwd=cwd,
89
+ env=os.environ.copy(),
90
+ )
91
+ except FileNotFoundError as e:
92
+ return {
93
+ "ok": False,
94
+ "returncode": -1,
95
+ "stdout": "",
96
+ "stderr": str(e),
97
+ "error": "executable_not_found",
98
+ }
99
+ try:
100
+ stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
101
+ except asyncio.TimeoutError:
102
+ try:
103
+ proc.kill()
104
+ except ProcessLookupError:
105
+ pass
106
+ return {
107
+ "ok": False,
108
+ "returncode": -1,
109
+ "stdout": "",
110
+ "stderr": f"Timed out after {timeout}s",
111
+ "error": "timeout",
112
+ }
113
+ stdout = (stdout_b or b"").decode("utf-8", errors="replace")
114
+ stderr = (stderr_b or b"").decode("utf-8", errors="replace")
115
+ code = proc.returncode if proc.returncode is not None else -1
116
+ return {
117
+ "ok": code == 0,
118
+ "returncode": code,
119
+ "stdout": stdout,
120
+ "stderr": stderr,
121
+ "error": None if code == 0 else "nonzero_exit",
122
+ }
123
+
124
+
125
+ def match_connected_device(
126
+ device_id: str,
127
+ connected: list[dict[str, str]],
128
+ *,
129
+ avd_by_serial: dict[str, str] | None = None,
130
+ ) -> dict[str, str] | None:
131
+ """Match adb serial, case-insensitive id, or running AVD name.
132
+
133
+ Android Studio shows AVD names (``Pixel_6``); ``adb devices`` shows
134
+ ``emulator-5554``. Config often has one while Maestro needs the other.
135
+ """
136
+ want = (device_id or "").strip()
137
+ if not want:
138
+ return None
139
+ want_l = want.lower().replace(" ", "_")
140
+ for d in connected:
141
+ did = str(d.get("id") or "")
142
+ name = str(d.get("name") or "")
143
+ if did == want or did.lower() == want.lower():
144
+ return d
145
+ if name.lower().replace(" ", "_") == want_l:
146
+ return d
147
+ for serial, avd_name in (avd_by_serial or {}).items():
148
+ if str(avd_name).strip().lower().replace(" ", "_") == want_l:
149
+ match = next((d for d in connected if d.get("id") == serial), None)
150
+ if match:
151
+ return match
152
+ return None
153
+
154
+
155
+ def _parse_adb_devices(stdout: str) -> list[dict[str, str]]:
156
+ devices: list[dict[str, str]] = []
157
+ for line in (stdout or "").splitlines():
158
+ line = line.strip()
159
+ if not line or line.startswith("List of devices"):
160
+ continue
161
+ parts = line.split()
162
+ if len(parts) >= 2 and parts[1] == "device":
163
+ serial = parts[0]
164
+ kind = "emulator" if serial.startswith("emulator-") else "device"
165
+ devices.append(
166
+ {
167
+ "id": serial,
168
+ "platform": "android",
169
+ "name": serial,
170
+ "state": "online",
171
+ "kind": kind,
172
+ "source": "adb",
173
+ "startable": "false",
174
+ }
175
+ )
176
+ return devices
177
+
178
+
179
+ async def _online_avd_names() -> dict[str, str]:
180
+ """Map emulator serial → AVD name for running emulators."""
181
+ adb = resolve_adb()
182
+ if not adb:
183
+ return {}
184
+ out: dict[str, str] = {}
185
+ for d in await list_android_online():
186
+ serial = d.get("id") or ""
187
+ if not serial.startswith("emulator-"):
188
+ continue
189
+ r = await _run_cmd(
190
+ [adb, "-s", serial, "emu", "avd", "name"],
191
+ timeout=10.0,
192
+ )
193
+ text = (r.get("stdout") or "") + "\n" + (r.get("stderr") or "")
194
+ for line in text.splitlines():
195
+ line = line.strip()
196
+ if line and line.upper() != "OK" and not line.lower().startswith("error"):
197
+ out[serial] = line
198
+ break
199
+ return out
200
+
201
+
202
+ async def list_android_online() -> list[dict[str, str]]:
203
+ adb = resolve_adb()
204
+ if not adb:
205
+ return []
206
+ result = await _run_cmd([adb, "devices"], timeout=15.0)
207
+ return _parse_adb_devices(result.get("stdout") or "")
208
+
209
+
210
+ async def list_android_avds() -> list[dict[str, str]]:
211
+ """AVDs that can be started (may already be running)."""
212
+ emu = resolve_emulator()
213
+ if not emu:
214
+ return []
215
+ result = await _run_cmd([emu, "-list-avds"], timeout=30.0)
216
+ if not result.get("ok") and not (result.get("stdout") or "").strip():
217
+ return []
218
+ online = {d["id"] for d in await list_android_online()}
219
+ # Heuristic: if any emulator-* online, treat first AVD match as running later
220
+ avds: list[dict[str, str]] = []
221
+ for line in (result.get("stdout") or "").splitlines():
222
+ name = line.strip()
223
+ if not name:
224
+ continue
225
+ # We can't map AVD name → emulator-5554 reliably without adb emu avd name;
226
+ # mark as available; online check refined in ensure_device.
227
+ avds.append(
228
+ {
229
+ "id": name,
230
+ "platform": "android",
231
+ "name": name,
232
+ "state": "available",
233
+ "kind": "avd",
234
+ "source": "emulator",
235
+ "startable": "true",
236
+ }
237
+ )
238
+ # Enrich: query running emulators for avd name
239
+ adb = resolve_adb()
240
+ running_avds: set[str] = set()
241
+ if adb:
242
+ for d in await list_android_online():
243
+ if not d["id"].startswith("emulator-"):
244
+ continue
245
+ r = await _run_cmd(
246
+ [adb, "-s", d["id"], "emu", "avd", "name"],
247
+ timeout=10.0,
248
+ )
249
+ # Output is "Pixel_6\nOK\n" or similar
250
+ text = (r.get("stdout") or "") + "\n" + (r.get("stderr") or "")
251
+ for line in text.splitlines():
252
+ line = line.strip()
253
+ if line and line.upper() != "OK" and not line.startswith("error"):
254
+ running_avds.add(line)
255
+ break
256
+ for avd in avds:
257
+ if avd["name"] in running_avds:
258
+ avd["state"] = "online"
259
+ avd["startable"] = "false"
260
+ del online # reserved
261
+ return avds
262
+
263
+
264
+ def _parse_simctl_all(stdout: str) -> list[dict[str, str]]:
265
+ """Parse `simctl list devices available` including Booted and Shutdown."""
266
+ devices: list[dict[str, str]] = []
267
+ current_runtime = ""
268
+ for line in (stdout or "").splitlines():
269
+ rt = re.match(r"^--\s+(.+?)\s+--$", line.strip())
270
+ if rt:
271
+ current_runtime = rt.group(1).strip()
272
+ continue
273
+ m = re.search(
274
+ r"^\s+(.+?)\s+\(([0-9A-Fa-f-]{36})\)\s+\((Booted|Shutdown|Creating)\)",
275
+ line,
276
+ )
277
+ if not m:
278
+ continue
279
+ name, udid, state = m.group(1).strip(), m.group(2), m.group(3)
280
+ if state == "Creating":
281
+ continue
282
+ # Skip unavailable markers in name
283
+ if "unavailable" in name.lower():
284
+ continue
285
+ devices.append(
286
+ {
287
+ "id": udid,
288
+ "platform": "ios",
289
+ "name": name,
290
+ "state": "online" if state == "Booted" else "available",
291
+ "kind": "simulator",
292
+ "source": "simctl",
293
+ "startable": "false" if state == "Booted" else "true",
294
+ "runtime": current_runtime,
295
+ }
296
+ )
297
+ return devices
298
+
299
+
300
+ async def list_ios_simulators(*, include_shutdown: bool = True) -> list[dict[str, str]]:
301
+ if not IS_MAC or not shutil.which("xcrun"):
302
+ return []
303
+ result = await _run_cmd(
304
+ ["xcrun", "simctl", "list", "devices", "available"],
305
+ timeout=25.0,
306
+ )
307
+ devices = _parse_simctl_all(result.get("stdout") or "")
308
+ if not include_shutdown:
309
+ devices = [d for d in devices if d.get("state") == "online"]
310
+ return devices
311
+
312
+
313
+ async def list_connected_devices() -> list[dict[str, str]]:
314
+ """Devices currently usable by Maestro (online adb + booted sims)."""
315
+ devices = await list_android_online()
316
+ if IS_MAC:
317
+ devices.extend(
318
+ [d for d in await list_ios_simulators(include_shutdown=False)]
319
+ )
320
+ return devices
321
+
322
+
323
+ async def list_all_targets() -> list[dict[str, str]]:
324
+ """Online devices + startable AVDs / iOS simulators."""
325
+ connected = await list_connected_devices()
326
+ by_id = {d["id"]: d for d in connected}
327
+ # Add AVDs not already represented
328
+ for avd in await list_android_avds():
329
+ if avd["state"] == "online":
330
+ # Prefer adb serial entries; keep AVD as informational if no serial map
331
+ continue
332
+ if avd["id"] not in by_id:
333
+ by_id[avd["id"]] = avd
334
+ if IS_MAC:
335
+ for sim in await list_ios_simulators(include_shutdown=True):
336
+ by_id[sim["id"]] = sim # booted overwrites with richer state
337
+ return list(by_id.values())
338
+
339
+
340
+ async def _wait_android_online(
341
+ *,
342
+ timeout_s: float = 120.0,
343
+ progress: ProgressFn = None,
344
+ ) -> Optional[dict[str, str]]:
345
+ adb = resolve_adb()
346
+ if not adb:
347
+ return None
348
+ deadline = time.monotonic() + timeout_s
349
+ while time.monotonic() < deadline:
350
+ # adb wait-for-device is per-default-device; poll instead
351
+ online = await list_android_online()
352
+ emus = [d for d in online if d["id"].startswith("emulator-")]
353
+ if emus:
354
+ # Wait until boot completed
355
+ serial = emus[0]["id"]
356
+ boot = await _run_cmd(
357
+ [adb, "-s", serial, "shell", "getprop", "sys.boot_completed"],
358
+ timeout=15.0,
359
+ )
360
+ val = (boot.get("stdout") or "").strip()
361
+ if val == "1":
362
+ if progress:
363
+ progress(f"Android emulator ready: {serial}")
364
+ return emus[0]
365
+ await asyncio.sleep(2.0)
366
+ if progress:
367
+ progress("Waiting for Android emulator to finish booting…")
368
+ return None
369
+
370
+
371
+ async def start_android_avd(
372
+ avd_name: str,
373
+ *,
374
+ timeout_s: float = 120.0,
375
+ progress: ProgressFn = None,
376
+ ) -> dict[str, Any]:
377
+ emu = resolve_emulator()
378
+ if not emu:
379
+ return {
380
+ "ok": False,
381
+ "error": "emulator_not_found",
382
+ "message": "Android emulator binary not found. Install Android Studio / SDK.",
383
+ }
384
+ if progress:
385
+ progress(f"Starting Android AVD: {avd_name}")
386
+ # Launch detached so it keeps running
387
+ try:
388
+ kwargs: dict[str, Any] = {
389
+ "stdout": asyncio.subprocess.DEVNULL,
390
+ "stderr": asyncio.subprocess.DEVNULL,
391
+ "env": os.environ.copy(),
392
+ }
393
+ if IS_WIN:
394
+ # Don't inherit console; detach
395
+ kwargs["creationflags"] = getattr(subprocess_mod(), "DETACHED_PROCESS", 0) | getattr(
396
+ subprocess_mod(), "CREATE_NEW_PROCESS_GROUP", 0
397
+ )
398
+ else:
399
+ kwargs["start_new_session"] = True
400
+ await asyncio.create_subprocess_exec(
401
+ emu,
402
+ "-avd",
403
+ avd_name,
404
+ "-netdelay",
405
+ "none",
406
+ "-netspeed",
407
+ "full",
408
+ **kwargs,
409
+ )
410
+ except Exception as e: # noqa: BLE001
411
+ return {"ok": False, "error": "start_failed", "message": str(e)}
412
+
413
+ device = await _wait_android_online(timeout_s=timeout_s, progress=progress)
414
+ if not device:
415
+ return {
416
+ "ok": False,
417
+ "error": "boot_timeout",
418
+ "message": f"AVD {avd_name} started but did not become ready in {timeout_s}s",
419
+ }
420
+ return {"ok": True, "device": device, "avd": avd_name}
421
+
422
+
423
+ def subprocess_mod():
424
+ import subprocess
425
+
426
+ return subprocess
427
+
428
+
429
+ async def start_ios_simulator(
430
+ udid: str,
431
+ *,
432
+ timeout_s: float = 90.0,
433
+ progress: ProgressFn = None,
434
+ ) -> dict[str, Any]:
435
+ if not IS_MAC:
436
+ return {
437
+ "ok": False,
438
+ "error": "ios_mac_only",
439
+ "message": "iOS Simulator can only be started on macOS (Xcode).",
440
+ }
441
+ if progress:
442
+ progress(f"Booting iOS Simulator: {udid}")
443
+ boot = await _run_cmd(["xcrun", "simctl", "boot", udid], timeout=60.0)
444
+ # Already booted is OK
445
+ err = (boot.get("stderr") or "") + (boot.get("stdout") or "")
446
+ if not boot.get("ok") and "current state: Booted" not in err and "Already booted" not in err:
447
+ # simctl returns non-zero if already booted on some versions — continue
448
+ if "Booted" not in err and boot.get("returncode") not in (0,):
449
+ # Still try open
450
+ pass
451
+ # Open Simulator.app UI
452
+ await _run_cmd(["open", "-a", "Simulator"], timeout=30.0)
453
+
454
+ deadline = time.monotonic() + timeout_s
455
+ while time.monotonic() < deadline:
456
+ sims = await list_ios_simulators(include_shutdown=False)
457
+ match = next((s for s in sims if s["id"] == udid), None)
458
+ if match:
459
+ if progress:
460
+ progress(f"iOS Simulator ready: {match.get('name')} ({udid})")
461
+ return {"ok": True, "device": match}
462
+ await asyncio.sleep(1.5)
463
+ if progress:
464
+ progress("Waiting for iOS Simulator to boot…")
465
+ return {
466
+ "ok": False,
467
+ "error": "boot_timeout",
468
+ "message": f"Simulator {udid} did not boot in {timeout_s}s",
469
+ }
470
+
471
+
472
+ async def _maestro_start_device(
473
+ *,
474
+ platform: str,
475
+ device_model: str = "",
476
+ device_os: str = "",
477
+ device_locale: str = "",
478
+ timeout_s: float = 120.0,
479
+ progress: ProgressFn = None,
480
+ ) -> dict[str, Any]:
481
+ """Best-effort `maestro start-device` then return a newly online device."""
482
+ from mobiflow.maestro import resolve_maestro_binary
483
+
484
+ binary = resolve_maestro_binary()
485
+ if not binary:
486
+ return {"ok": False, "error": "maestro_not_installed"}
487
+
488
+ plat = (platform or "android").lower()
489
+ if plat not in {"android", "ios", "web"}:
490
+ plat = "android"
491
+
492
+ args = [binary, "start-device", "--platform", plat]
493
+ model = (device_model or "").strip()
494
+ # Don't pass raw UDIDs / emulator-XXXX as --device-model
495
+ if model and not re.fullmatch(r"[0-9A-Fa-f-]{36}", model) and not model.startswith(
496
+ "emulator-"
497
+ ):
498
+ args.extend(["--device-model", model])
499
+ if (device_os or "").strip():
500
+ args.extend(["--device-os", device_os.strip()])
501
+ if (device_locale or "").strip():
502
+ args.extend(["--device-locale", device_locale.strip()])
503
+
504
+ if progress:
505
+ progress(f"Starting device via Maestro CLI ({plat})…")
506
+ result = await _run_cmd(args, timeout=max(60.0, float(timeout_s)))
507
+ if not result.get("ok"):
508
+ return {
509
+ "ok": False,
510
+ "error": "maestro_start_device_failed",
511
+ "stderr": result.get("stderr") or result.get("stdout") or "",
512
+ }
513
+
514
+ deadline = time.monotonic() + float(timeout_s)
515
+ while time.monotonic() < deadline:
516
+ connected = await list_connected_devices()
517
+ for d in connected:
518
+ if d.get("platform") == plat:
519
+ if progress:
520
+ progress(
521
+ f"Maestro device ready: {d.get('name')} ({d.get('id')})"
522
+ )
523
+ return {
524
+ "ok": True,
525
+ "device": d,
526
+ "started": True,
527
+ "via": "maestro start-device",
528
+ }
529
+ if connected:
530
+ return {
531
+ "ok": True,
532
+ "device": connected[0],
533
+ "started": True,
534
+ "via": "maestro start-device",
535
+ }
536
+ await asyncio.sleep(1.5)
537
+ return {
538
+ "ok": False,
539
+ "error": "maestro_start_device_timeout",
540
+ "message": f"maestro start-device ran but no {plat} device came online",
541
+ }
542
+
543
+
544
+ async def ensure_device(
545
+ *,
546
+ platform_pref: str = "android",
547
+ device_id: Optional[str] = None,
548
+ auto_start: bool = True,
549
+ timeout_s: float = 120.0,
550
+ progress: ProgressFn = None,
551
+ use_maestro_cli: bool = True,
552
+ device_model: str = "",
553
+ device_os: str = "",
554
+ device_locale: str = "",
555
+ ) -> dict[str, Any]:
556
+ """Return an online device; optionally start AVD / iOS sim if none connected.
557
+
558
+ Preference order when auto-starting:
559
+ - If ``use_maestro_cli``: try ``maestro start-device`` / ``maestro list-devices``
560
+ - If device_id set: start that AVD name or iOS UDID
561
+ - Else platform android: first available AVD
562
+ - Else platform ios (macOS): first available iPhone simulator
563
+ """
564
+ plat = (platform_pref or "android").lower()
565
+
566
+ def _p(msg: str) -> None:
567
+ if progress:
568
+ progress(msg)
569
+
570
+ # Explicit device already online?
571
+ connected = await list_connected_devices()
572
+ avd_by_serial = await _online_avd_names() if plat == "android" else {}
573
+ if device_id:
574
+ match = match_connected_device(
575
+ device_id, connected, avd_by_serial=avd_by_serial
576
+ )
577
+ if match:
578
+ return {"ok": True, "device": match, "started": False}
579
+ # Stale serial / AVD label in config while an emulator is already up.
580
+ android_online = [d for d in connected if d.get("platform") == "android"]
581
+ if plat == "android" and android_online:
582
+ pick = android_online[0]
583
+ _p(
584
+ f"Requested device {device_id!r} not in adb list "
585
+ f"(have {[d.get('id') for d in android_online]}); "
586
+ f"using {pick.get('id')}"
587
+ )
588
+ return {"ok": True, "device": pick, "started": False}
589
+ else:
590
+ for d in connected:
591
+ if d.get("platform") == plat:
592
+ _p(f"Using connected {plat} device: {d.get('name')} ({d.get('id')})")
593
+ return {"ok": True, "device": d, "started": False}
594
+ if connected:
595
+ _p(
596
+ f"No {plat} device online — using {connected[0].get('platform')} "
597
+ f"{connected[0].get('name')}"
598
+ )
599
+ return {"ok": True, "device": connected[0], "started": False}
600
+
601
+ if not auto_start:
602
+ return {
603
+ "ok": False,
604
+ "error": "no_device",
605
+ "message": "No devices connected. Start an emulator/simulator or enable auto_start.",
606
+ "connected": connected,
607
+ "targets": await list_all_targets(),
608
+ }
609
+
610
+ # Prefer Maestro CLI device management when available
611
+ if use_maestro_cli:
612
+ started = await _maestro_start_device(
613
+ platform=plat,
614
+ device_model=device_model or device_id or "",
615
+ device_os=device_os,
616
+ device_locale=device_locale,
617
+ timeout_s=timeout_s,
618
+ progress=progress,
619
+ )
620
+ if started.get("ok"):
621
+ return started
622
+
623
+ if device_id:
624
+ # Maybe it's an AVD name or shutdown sim
625
+ avds = await list_android_avds()
626
+ avd = next((a for a in avds if a["id"] == device_id or a["name"] == device_id), None)
627
+ if avd and avd.get("startable") == "true":
628
+ started = await start_android_avd(
629
+ avd["name"], timeout_s=timeout_s, progress=progress
630
+ )
631
+ if started.get("ok"):
632
+ return {
633
+ "ok": True,
634
+ "device": started["device"],
635
+ "started": True,
636
+ "avd": avd["name"],
637
+ }
638
+ return started
639
+ if IS_MAC and re.fullmatch(r"[0-9A-Fa-f-]{36}", device_id):
640
+ started = await start_ios_simulator(
641
+ device_id, timeout_s=timeout_s, progress=progress
642
+ )
643
+ if started.get("ok"):
644
+ return {"ok": True, "device": started["device"], "started": True}
645
+ return started
646
+ return {
647
+ "ok": False,
648
+ "error": "device_not_found",
649
+ "message": f"Device {device_id!r} not connected and could not be started.",
650
+ "connected": await list_connected_devices(),
651
+ }
652
+
653
+ # Auto-start via adb/simctl fallback
654
+ if plat == "ios":
655
+ if not IS_MAC:
656
+ # Fall back to Android on Windows/Linux
657
+ _p("iOS Simulator unavailable on this OS — trying Android AVD…")
658
+ plat = "android"
659
+ else:
660
+ sims = await list_ios_simulators(include_shutdown=True)
661
+ # Prefer iPhone, available (not booted)
662
+ candidates = [
663
+ s
664
+ for s in sims
665
+ if s.get("state") == "available" and "iphone" in s.get("name", "").lower()
666
+ ]
667
+ if not candidates:
668
+ candidates = [s for s in sims if s.get("state") == "available"]
669
+ if not candidates:
670
+ return {
671
+ "ok": False,
672
+ "error": "no_ios_simulator",
673
+ "message": "No iOS simulators available. Install Xcode + run Xcode once.",
674
+ }
675
+ pick = candidates[0]
676
+ started = await start_ios_simulator(
677
+ pick["id"], timeout_s=timeout_s, progress=progress
678
+ )
679
+ if started.get("ok"):
680
+ return {"ok": True, "device": started["device"], "started": True}
681
+ return started
682
+
683
+ # Android AVD
684
+ avds = await list_android_avds()
685
+ startable = [a for a in avds if a.get("startable") == "true"]
686
+ if not startable:
687
+ # Maybe emulator binary missing
688
+ if not resolve_emulator():
689
+ return {
690
+ "ok": False,
691
+ "error": "emulator_not_found",
692
+ "message": (
693
+ "No Android emulator binary. Install Android Studio and create an AVD "
694
+ "(Tools → Device Manager). Set ANDROID_HOME if needed."
695
+ ),
696
+ }
697
+ return {
698
+ "ok": False,
699
+ "error": "no_avd",
700
+ "message": "No Android Virtual Devices found. Create one in Android Studio Device Manager.",
701
+ }
702
+ pick = startable[0]
703
+ started = await start_android_avd(pick["name"], timeout_s=timeout_s, progress=progress)
704
+ if started.get("ok"):
705
+ return {"ok": True, "device": started["device"], "started": True, "avd": pick["name"]}
706
+ return started
707
+
708
+
709
+ def host_capabilities() -> dict[str, Any]:
710
+ return {
711
+ "os": platform.system(),
712
+ "android_adb": resolve_adb(),
713
+ "android_emulator": resolve_emulator(),
714
+ "ios_simctl": bool(IS_MAC and shutil.which("xcrun")),
715
+ "can_start_android": bool(resolve_emulator()),
716
+ "can_start_ios": bool(IS_MAC and shutil.which("xcrun")),
717
+ }