feat: discover and add connected iOS devices
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
This commit is contained in:
@@ -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"]))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -31,14 +31,30 @@
|
||||
{% endfor %}</tbody>
|
||||
</table>
|
||||
<h2>{{ "Edit device" if edit_record else "Add device" }}</h2>
|
||||
<button type="button" id="discover-ios">Scan connected iPhones</button>
|
||||
<p id="discovery-status" class="screenshot-status"></p>
|
||||
<div id="discovered-devices"></div>
|
||||
<form method="post" action="/devices/save">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Device ID <input type="text" name="device_id" value="{{ edit_record["device_id"] if edit_record else "" }}" required></label><br>
|
||||
<label>Name <input type="text" name="name" value="{{ edit_record["name"] if edit_record else "" }}"></label><br>
|
||||
<label>Driver type <input type="text" name="driver_type" value="{{ edit_record["driver_type"] if edit_record else "wda" }}" required></label><br>
|
||||
<label>Connection info (JSON)<br>
|
||||
<textarea name="connection_info" rows="3" cols="50">{{ connection_info_json }}</textarea>
|
||||
<label>Platform and protocol
|
||||
<select name="driver_type" id="driver-type" required>
|
||||
<option value="wda"{% if not edit_record or edit_record["driver_type"] == "wda" %} selected{% endif %}>iOS - Appium / XCUITest (WDA)</option>
|
||||
<option value="uiautomator2"{% if edit_record and edit_record["driver_type"] == "uiautomator2" %} selected{% endif %}>Android - Appium / UiAutomator2</option>
|
||||
</select>
|
||||
</label><br>
|
||||
<label>Appium server URL <input type="url" name="server_url" value="{{ edit_record["connection_info"].get("server_url", "http://127.0.0.1:4723") if edit_record else "http://127.0.0.1:4723" }}" required></label><br>
|
||||
<label>Device UDID <input type="text" name="udid" value="{{ edit_record["connection_info"].get("udid", "") if edit_record else "" }}" required></label><br>
|
||||
<label>Device name <input type="text" name="device_name" value="{{ edit_record["connection_info"].get("device_name", "") if edit_record else "" }}" placeholder="iPhone"></label><br>
|
||||
<label class="ios-setting">WDA local port <input type="number" min="1" max="65535" name="wda_local_port" value="{{ edit_record["connection_info"].get("wda_local_port", "") if edit_record else "" }}" placeholder="8100"></label>
|
||||
<label class="android-setting">UiAutomator2 system port <input type="number" min="1" max="65535" name="system_port" value="{{ edit_record["connection_info"].get("system_port", "") if edit_record else "" }}" placeholder="8200"></label><br>
|
||||
<details>
|
||||
<summary>Advanced connection capabilities (JSON)</summary>
|
||||
<textarea name="connection_info" rows="4" cols="60">{{ connection_info_json }}</textarea>
|
||||
</details>
|
||||
<p id="connection-test-status" class="screenshot-status"></p>
|
||||
<button type="button" id="test-connection">Test connection</button>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<style>
|
||||
@@ -49,8 +65,100 @@
|
||||
</style>
|
||||
<script>
|
||||
(() => {
|
||||
const driverType = document.getElementById("driver-type");
|
||||
const syncPlatformFields = () => {
|
||||
const ios = driverType.value === "wda";
|
||||
document.querySelectorAll(".ios-setting").forEach((el) => { el.hidden = !ios; });
|
||||
document.querySelectorAll(".android-setting").forEach((el) => { el.hidden = ios; });
|
||||
};
|
||||
driverType.addEventListener("change", syncPlatformFields);
|
||||
syncPlatformFields();
|
||||
const csrfInput = document.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfInput ? csrfInput.value : "";
|
||||
const form = document.querySelector('form[action="/devices/save"]');
|
||||
const discoverButton = document.getElementById("discover-ios");
|
||||
const discoveryStatus = document.getElementById("discovery-status");
|
||||
const discoveredDevices = document.getElementById("discovered-devices");
|
||||
discoverButton.addEventListener("click", async () => {
|
||||
discoverButton.disabled = true;
|
||||
discoveryStatus.classList.remove("error");
|
||||
discoveryStatus.textContent = "Scanning...";
|
||||
discoveredDevices.replaceChildren();
|
||||
try {
|
||||
const response = await fetch("/api/devices/discover-ios");
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.detail || "Discovery failed.");
|
||||
discoveryStatus.textContent = payload.devices.length
|
||||
? `Found ${payload.devices.length} connected iPhone(s).`
|
||||
: "No connected, paired iPhones found.";
|
||||
payload.devices.forEach((device, index) => {
|
||||
const row = document.createElement("p");
|
||||
const description = document.createElement("span");
|
||||
description.textContent = `${device.name} - ${device.model} - iOS ${device.os_version} (${device.transport}) `;
|
||||
const select = document.createElement("button");
|
||||
select.type = "button";
|
||||
select.textContent = device.configured ? "Already added" : "Use this iPhone";
|
||||
select.disabled = device.configured;
|
||||
select.addEventListener("click", () => {
|
||||
form.elements.driver_type.value = "wda";
|
||||
form.elements.udid.value = device.udid;
|
||||
form.elements.device_name.value = device.name;
|
||||
form.elements.wda_local_port.value = device.suggested_wda_port;
|
||||
form.elements.device_id.value ||= `ios-phone-${index + 1}`;
|
||||
form.elements.name.value ||= device.name;
|
||||
const advanced = JSON.parse(form.elements.connection_info.value || "{}");
|
||||
advanced.mjpegServerPort = device.suggested_mjpeg_port;
|
||||
advanced.derivedDataPath = `/tmp/wda-${device.udid}`;
|
||||
form.elements.connection_info.value = JSON.stringify(advanced, null, 2);
|
||||
syncPlatformFields();
|
||||
form.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
row.append(description, select);
|
||||
discoveredDevices.append(row);
|
||||
});
|
||||
} catch (error) {
|
||||
discoveryStatus.classList.add("error");
|
||||
discoveryStatus.textContent = error.message || "Discovery failed.";
|
||||
} finally {
|
||||
discoverButton.disabled = false;
|
||||
}
|
||||
});
|
||||
const testButton = document.getElementById("test-connection");
|
||||
const testStatus = document.getElementById("connection-test-status");
|
||||
const connectionInfo = () => {
|
||||
const data = new FormData(form);
|
||||
let info = {};
|
||||
const advanced = String(data.get("connection_info") || "{}");
|
||||
info = JSON.parse(advanced);
|
||||
["server_url", "udid", "device_name"].forEach((key) => {
|
||||
const value = String(data.get(key) || "").trim();
|
||||
if (value) info[key] = value;
|
||||
});
|
||||
const portKey = data.get("driver_type") === "wda" ? "wda_local_port" : "system_port";
|
||||
const port = String(data.get(portKey) || "").trim();
|
||||
if (port) info[portKey] = Number(port);
|
||||
return { driver_type: data.get("driver_type"), connection_info: info };
|
||||
};
|
||||
testButton.addEventListener("click", async () => {
|
||||
testButton.disabled = true;
|
||||
testStatus.classList.remove("error");
|
||||
testStatus.textContent = "Testing connection...";
|
||||
try {
|
||||
const response = await fetch("/api/devices/test-connection", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
|
||||
body: JSON.stringify(connectionInfo()),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.detail || "Connection test failed.");
|
||||
testStatus.textContent = "Connection successful.";
|
||||
} catch (error) {
|
||||
testStatus.classList.add("error");
|
||||
testStatus.textContent = error.message || "Connection test failed.";
|
||||
} finally {
|
||||
testButton.disabled = false;
|
||||
}
|
||||
});
|
||||
document.querySelectorAll(".screenshot-button").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const deviceId = button.dataset.deviceId;
|
||||
|
||||
@@ -314,6 +314,75 @@ def test_device_screenshot_requires_csrf_and_connected_device(tmp_path) -> None:
|
||||
assert offline.status_code == 503
|
||||
|
||||
|
||||
def test_device_connection_test_connects_checks_health_and_disconnects(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class ProbeDriver:
|
||||
def connect(self) -> None:
|
||||
events.append("connect")
|
||||
|
||||
def health_check(self) -> None:
|
||||
events.append("health")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
events.append("disconnect")
|
||||
|
||||
def factory(driver_type, connection_info):
|
||||
assert driver_type == "wda"
|
||||
assert connection_info["udid"] == "ios-udid"
|
||||
return ProbeDriver
|
||||
|
||||
monkeypatch.setattr("host_agent.web.app.build_driver_factory", factory)
|
||||
client, context = _build_client(tmp_path)
|
||||
csrf_token = _login(client)
|
||||
|
||||
response = client.post(
|
||||
"/api/devices/test-connection",
|
||||
json={"driver_type": "wda", "connection_info": {"udid": "ios-udid"}},
|
||||
headers={"X-CSRF-Token": csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True, "driver_type": "wda"}
|
||||
assert events == ["connect", "health", "disconnect"]
|
||||
assert context["config_store"].list() == []
|
||||
|
||||
|
||||
def test_ios_discovery_returns_connected_devices_and_unique_ports(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"host_agent.web.app.discover_connected_ios_devices",
|
||||
lambda: [
|
||||
{
|
||||
"udid": "ios-new",
|
||||
"name": "New iPhone",
|
||||
"model": "iPhone 15",
|
||||
"os_version": "18.0",
|
||||
"transport": "wired",
|
||||
}
|
||||
],
|
||||
)
|
||||
client, context = _build_client(tmp_path)
|
||||
context["config_store"].add(
|
||||
device_id="existing",
|
||||
driver_type="wda",
|
||||
connection_info={"udid": "ios-old", "wda_local_port": 8100, "mjpegServerPort": 9100},
|
||||
)
|
||||
_login(client)
|
||||
|
||||
response = client.get("/api/devices/discover-ios")
|
||||
|
||||
assert response.status_code == 200
|
||||
device = response.json()["devices"][0]
|
||||
assert device["udid"] == "ios-new"
|
||||
assert device["configured"] is False
|
||||
assert device["suggested_wda_port"] == 8101
|
||||
assert device["suggested_mjpeg_port"] == 9101
|
||||
|
||||
|
||||
def test_remove_device_unregisters_from_manager(tmp_path) -> None:
|
||||
client, context = _build_client(tmp_path)
|
||||
csrf_token = _login(client)
|
||||
|
||||
Reference in New Issue
Block a user