From 9076f8ddb0072fefad640de7edf5b5a234363d1c Mon Sep 17 00:00:00 2001 From: showtan001 <240788545@qq.com> Date: Mon, 7 Sep 2026 18:33:49 +0800 Subject: [PATCH] feat: discover and add connected iOS devices --- .../host_agent/ios_discovery.py | 79 ++++++++++++ apps/device-host-agent/host_agent/web/app.py | 103 ++++++++++++++++ .../host_agent/web/templates/devices.html | 114 +++++++++++++++++- apps/device-host-agent/tests/test_web_app.py | 69 +++++++++++ 4 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 apps/device-host-agent/host_agent/ios_discovery.py diff --git a/apps/device-host-agent/host_agent/ios_discovery.py b/apps/device-host-agent/host_agent/ios_discovery.py new file mode 100644 index 0000000..11754ed --- /dev/null +++ b/apps/device-host-agent/host_agent/ios_discovery.py @@ -0,0 +1,79 @@ +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"])) + diff --git a/apps/device-host-agent/host_agent/web/app.py b/apps/device-host-agent/host_agent/web/app.py index a787f8f..c24c8ac 100644 --- a/apps/device-host-agent/host_agent/web/app.py +++ b/apps/device-host-agent/host_agent/web/app.py @@ -13,6 +13,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp from core.errors import DeviceNotFoundError, DeviceOfflineError, DeviceRuntimeError from device.manager import DeviceManager +from driver.registry import build_driver_factory from host_agent.assignment import AssignmentExecutor from host_agent.client import ( HostAgentClient, @@ -26,6 +27,7 @@ from host_agent.conversation_log import ConversationLogStore from host_agent.devices import register_local_device, unregister_local_device from host_agent.history import ConsoleHistoryStore from host_agent.identity import HostIdentityStore +from host_agent.ios_discovery import IOSDiscoveryError, discover_connected_ios_devices from host_agent.local_account import LocalAccountStore from host_agent.mcp_lock import McpBusyTracker from host_agent.mcp_token import McpTokenStore @@ -526,6 +528,87 @@ def create_console_app( }, ) + @app.get("/api/devices/discover-ios") + async def api_discover_ios_devices( + session: SessionState = Depends(require_session), + ) -> JSONResponse: + try: + discovered = await asyncio.to_thread(discover_connected_ios_devices) + except IOSDiscoveryError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + configured = await asyncio.to_thread(config_store.list) + configured_udids = { + str(record["connection_info"].get("udid")) + for record in configured + if record["driver_type"] == "wda" + } + used_wda_ports = { + record["connection_info"].get("wda_local_port") for record in configured + } + used_mjpeg_ports = { + record["connection_info"].get("mjpegServerPort") for record in configured + } + next_wda_port = 8100 + next_mjpeg_port = 9100 + result = [] + for device in discovered: + while next_wda_port in used_wda_ports: + next_wda_port += 1 + while next_mjpeg_port in used_mjpeg_ports: + next_mjpeg_port += 1 + result.append( + { + **device, + "configured": device["udid"] in configured_udids, + "suggested_wda_port": next_wda_port, + "suggested_mjpeg_port": next_mjpeg_port, + } + ) + used_wda_ports.add(next_wda_port) + used_mjpeg_ports.add(next_mjpeg_port) + next_wda_port += 1 + next_mjpeg_port += 1 + return JSONResponse({"devices": result}) + + @app.post("/api/devices/test-connection") + async def api_device_test_connection( + request: Request, + session: SessionState = Depends(require_csrf), + ) -> JSONResponse: + payload = await request.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Request must be an object.") + driver_type = str(payload.get("driver_type", "")).strip() + connection_info = payload.get("connection_info") + if not driver_type or not isinstance(connection_info, dict): + raise HTTPException( + status_code=400, + detail="Driver type and connection info are required.", + ) + + driver = None + connected = False + try: + driver = build_driver_factory(driver_type, connection_info)() + await asyncio.to_thread(driver.connect) + connected = True + await asyncio.to_thread(driver.health_check) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status_code=502, + detail=str(exc) or "device connection test failed", + ) from exc + finally: + if connected and driver is not None: + try: + await asyncio.to_thread(driver.disconnect) + except Exception: + pass + return JSONResponse({"ok": True, "driver_type": driver_type}) + @app.post("/devices/save") async def devices_save( request: Request, @@ -552,6 +635,26 @@ def create_console_app( else: connection_info = parsed + if error is None: + # The console exposes the common Appium settings as regular form + # fields. Advanced JSON remains available for uncommon capabilities. + field_map = { + "server_url": "server_url", + "udid": "udid", + "device_name": "device_name", + } + for form_key, config_key in field_map.items(): + value = str(form.get(form_key, "")).strip() + if value: + connection_info[config_key] = value + port_key = "wda_local_port" if driver_type == "wda" else "system_port" + port_value = str(form.get(port_key, "")).strip() + if port_value: + try: + connection_info[port_key] = int(port_value) + except ValueError: + error = f"{port_key} must be an integer." + if error is None: try: await asyncio.to_thread( diff --git a/apps/device-host-agent/host_agent/web/templates/devices.html b/apps/device-host-agent/host_agent/web/templates/devices.html index 6f68e74..1384012 100644 --- a/apps/device-host-agent/host_agent/web/templates/devices.html +++ b/apps/device-host-agent/host_agent/web/templates/devices.html @@ -31,14 +31,30 @@ {% endfor %}