from __future__ import annotations import json import subprocess import tempfile from pathlib import Path from typing import Any class IOSDiscoveryError(RuntimeError): pass def discover_connected_ios_devices(*, timeout_seconds: int = 10) -> list[dict[str, str]]: """Return paired, currently connected physical iOS devices from CoreDevice.""" with tempfile.TemporaryDirectory(prefix="ios-device-discovery-") as temp_dir: output_path = Path(temp_dir) / "devices.json" try: completed = subprocess.run( [ "xcrun", "devicectl", "list", "devices", "--json-output", str(output_path), "--timeout", str(timeout_seconds), "--quiet", ], capture_output=True, text=True, timeout=timeout_seconds + 2, check=False, ) except FileNotFoundError as exc: raise IOSDiscoveryError("xcrun is unavailable; install Xcode command line tools") from exc except subprocess.TimeoutExpired as exc: raise IOSDiscoveryError("iOS device discovery timed out") from exc if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() raise IOSDiscoveryError(detail or "devicectl failed to discover iOS devices") try: payload = json.loads(output_path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: raise IOSDiscoveryError("devicectl returned invalid device data") from exc raw_devices = payload.get("result", {}).get("devices", []) devices: list[dict[str, str]] = [] for item in raw_devices if isinstance(raw_devices, list) else []: if not isinstance(item, dict): continue hardware = item.get("hardwareProperties", {}) properties = item.get("deviceProperties", {}) connection = item.get("connectionProperties", {}) if not all(isinstance(value, dict) for value in (hardware, properties, connection)): continue udid = hardware.get("udid") if ( hardware.get("platform") != "iOS" or hardware.get("reality") != "physical" or connection.get("pairingState") != "paired" or connection.get("tunnelState") != "connected" or not isinstance(udid, str) or not udid ): continue devices.append( { "udid": udid, "name": str(properties.get("name") or hardware.get("marketingName") or "iPhone"), "model": str(hardware.get("marketingName") or hardware.get("productType") or "iPhone"), "os_version": str(properties.get("osVersionNumber") or ""), "transport": str(connection.get("transportType") or "unknown"), } ) return sorted(devices, key=lambda device: (device["name"], device["udid"]))