feat: Added iOS support
This commit is contained in:
382
phone_agent/xctest/connection.py
Normal file
382
phone_agent/xctest/connection.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""iOS device connection management via idevice tools and WebDriverAgent."""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ConnectionType(Enum):
|
||||
"""Type of iOS connection."""
|
||||
|
||||
USB = "usb"
|
||||
NETWORK = "network"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Information about a connected iOS device."""
|
||||
|
||||
device_id: str # UDID
|
||||
status: str
|
||||
connection_type: ConnectionType
|
||||
model: str | None = None
|
||||
ios_version: str | None = None
|
||||
device_name: str | None = None
|
||||
|
||||
|
||||
class XCTestConnection:
|
||||
"""
|
||||
Manages connections to iOS devices via libimobiledevice and WebDriverAgent.
|
||||
|
||||
Requires:
|
||||
- libimobiledevice (idevice_id, ideviceinfo)
|
||||
- WebDriverAgent running on the iOS device
|
||||
- ios-deploy (optional, for app installation)
|
||||
|
||||
Example:
|
||||
>>> conn = XCTestConnection()
|
||||
>>> # List connected devices
|
||||
>>> devices = conn.list_devices()
|
||||
>>> # Get device info
|
||||
>>> info = conn.get_device_info()
|
||||
>>> # Check if WDA is running
|
||||
>>> is_ready = conn.is_wda_ready()
|
||||
"""
|
||||
|
||||
def __init__(self, wda_url: str = "http://localhost:8100"):
|
||||
"""
|
||||
Initialize iOS connection manager.
|
||||
|
||||
Args:
|
||||
wda_url: WebDriverAgent URL (default: http://localhost:8100).
|
||||
For network devices, use http://<device-ip>:8100
|
||||
"""
|
||||
self.wda_url = wda_url.rstrip("/")
|
||||
|
||||
def list_devices(self) -> list[DeviceInfo]:
|
||||
"""
|
||||
List all connected iOS devices.
|
||||
|
||||
Returns:
|
||||
List of DeviceInfo objects.
|
||||
|
||||
Note:
|
||||
Requires libimobiledevice to be installed.
|
||||
Install on macOS: brew install libimobiledevice
|
||||
"""
|
||||
try:
|
||||
# Get list of device UDIDs
|
||||
result = subprocess.run(
|
||||
["idevice_id", "-ln"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
devices = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
udid = line.strip()
|
||||
if not udid:
|
||||
continue
|
||||
|
||||
# Determine connection type (network devices have specific format)
|
||||
conn_type = (
|
||||
ConnectionType.NETWORK
|
||||
if "-" in udid and len(udid) > 40
|
||||
else ConnectionType.USB
|
||||
)
|
||||
|
||||
# Get detailed device info
|
||||
device_info = self._get_device_details(udid)
|
||||
|
||||
devices.append(
|
||||
DeviceInfo(
|
||||
device_id=udid,
|
||||
status="connected",
|
||||
connection_type=conn_type,
|
||||
model=device_info.get("model"),
|
||||
ios_version=device_info.get("ios_version"),
|
||||
device_name=device_info.get("name"),
|
||||
)
|
||||
)
|
||||
|
||||
return devices
|
||||
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"Error: idevice_id not found. Install libimobiledevice: brew install libimobiledevice"
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error listing devices: {e}")
|
||||
return []
|
||||
|
||||
def _get_device_details(self, udid: str) -> dict[str, str]:
|
||||
"""
|
||||
Get detailed information about a specific device.
|
||||
|
||||
Args:
|
||||
udid: Device UDID.
|
||||
|
||||
Returns:
|
||||
Dictionary with device details.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ideviceinfo", "-u", udid],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
info = {}
|
||||
for line in result.stdout.split("\n"):
|
||||
if ": " in line:
|
||||
key, value = line.split(": ", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
if key == "ProductType":
|
||||
info["model"] = value
|
||||
elif key == "ProductVersion":
|
||||
info["ios_version"] = value
|
||||
elif key == "DeviceName":
|
||||
info["name"] = value
|
||||
|
||||
return info
|
||||
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_device_info(self, device_id: str | None = None) -> DeviceInfo | None:
|
||||
"""
|
||||
Get detailed information about a device.
|
||||
|
||||
Args:
|
||||
device_id: Device UDID. If None, uses first available device.
|
||||
|
||||
Returns:
|
||||
DeviceInfo or None if not found.
|
||||
"""
|
||||
devices = self.list_devices()
|
||||
|
||||
if not devices:
|
||||
return None
|
||||
|
||||
if device_id is None:
|
||||
return devices[0]
|
||||
|
||||
for device in devices:
|
||||
if device.device_id == device_id:
|
||||
return device
|
||||
|
||||
return None
|
||||
|
||||
def is_connected(self, device_id: str | None = None) -> bool:
|
||||
"""
|
||||
Check if a device is connected.
|
||||
|
||||
Args:
|
||||
device_id: Device UDID to check. If None, checks if any device is connected.
|
||||
|
||||
Returns:
|
||||
True if connected, False otherwise.
|
||||
"""
|
||||
devices = self.list_devices()
|
||||
|
||||
if not devices:
|
||||
return False
|
||||
|
||||
if device_id is None:
|
||||
return len(devices) > 0
|
||||
|
||||
return any(d.device_id == device_id for d in devices)
|
||||
|
||||
def is_wda_ready(self, timeout: int = 2) -> bool:
|
||||
"""
|
||||
Check if WebDriverAgent is running and accessible.
|
||||
|
||||
Args:
|
||||
timeout: Request timeout in seconds.
|
||||
|
||||
Returns:
|
||||
True if WDA is ready, False otherwise.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
f"{self.wda_url}/status", timeout=timeout, verify=False
|
||||
)
|
||||
return response.status_code == 200
|
||||
except ImportError:
|
||||
print(
|
||||
"Error: requests library not found. Install it: pip install requests"
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def start_wda_session(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Start a new WebDriverAgent session.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, session_id or error_message).
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
f"{self.wda_url}/session",
|
||||
json={"capabilities": {}},
|
||||
timeout=30,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
data = response.json()
|
||||
session_id = data.get("sessionId") or data.get("value", {}).get(
|
||||
"sessionId"
|
||||
)
|
||||
return True, session_id or "session_started"
|
||||
else:
|
||||
return False, f"Failed to start session: {response.text}"
|
||||
|
||||
except ImportError:
|
||||
return (
|
||||
False,
|
||||
"requests library not found. Install it: pip install requests",
|
||||
)
|
||||
except Exception as e:
|
||||
return False, f"Error starting WDA session: {e}"
|
||||
|
||||
def get_wda_status(self) -> dict | None:
|
||||
"""
|
||||
Get WebDriverAgent status information.
|
||||
|
||||
Returns:
|
||||
Status dictionary or None if not available.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
response = requests.get(f"{self.wda_url}/status", timeout=5, verify=False)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def pair_device(self, device_id: str | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Pair with an iOS device (required for some operations).
|
||||
|
||||
Args:
|
||||
device_id: Device UDID. If None, uses first available device.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
"""
|
||||
try:
|
||||
cmd = ["idevicepair"]
|
||||
if device_id:
|
||||
cmd.extend(["-u", device_id])
|
||||
cmd.append("pair")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
if "SUCCESS" in output or "already paired" in output.lower():
|
||||
return True, "Device paired successfully"
|
||||
else:
|
||||
return False, output.strip()
|
||||
|
||||
except FileNotFoundError:
|
||||
return (
|
||||
False,
|
||||
"idevicepair not found. Install libimobiledevice: brew install libimobiledevice",
|
||||
)
|
||||
except Exception as e:
|
||||
return False, f"Error pairing device: {e}"
|
||||
|
||||
def get_device_name(self, device_id: str | None = None) -> str | None:
|
||||
"""
|
||||
Get the device name.
|
||||
|
||||
Args:
|
||||
device_id: Device UDID. If None, uses first available device.
|
||||
|
||||
Returns:
|
||||
Device name string or None if not found.
|
||||
"""
|
||||
try:
|
||||
cmd = ["ideviceinfo"]
|
||||
if device_id:
|
||||
cmd.extend(["-u", device_id])
|
||||
cmd.extend(["-k", "DeviceName"])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
|
||||
return result.stdout.strip() or None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting device name: {e}")
|
||||
return None
|
||||
|
||||
def restart_wda(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Restart WebDriverAgent (requires manual restart on device).
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
|
||||
Note:
|
||||
This method only checks if WDA needs restart.
|
||||
Actual restart requires re-running WDA on the device via Xcode or other means.
|
||||
"""
|
||||
if self.is_wda_ready():
|
||||
return True, "WDA is already running"
|
||||
else:
|
||||
return (
|
||||
False,
|
||||
"WDA is not running. Please start it manually on the device.",
|
||||
)
|
||||
|
||||
|
||||
def quick_connect(wda_url: str = "http://localhost:8100") -> tuple[bool, str]:
|
||||
"""
|
||||
Quick helper to check iOS device connection and WDA status.
|
||||
|
||||
Args:
|
||||
wda_url: WebDriverAgent URL.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
"""
|
||||
conn = XCTestConnection(wda_url=wda_url)
|
||||
|
||||
# Check if device is connected
|
||||
if not conn.is_connected():
|
||||
return False, "No iOS device connected"
|
||||
|
||||
# Check if WDA is ready
|
||||
if not conn.is_wda_ready():
|
||||
return False, "WebDriverAgent is not running"
|
||||
|
||||
return True, "iOS device connected and WDA ready"
|
||||
|
||||
|
||||
def list_devices() -> list[DeviceInfo]:
|
||||
"""
|
||||
Quick helper to list connected iOS devices.
|
||||
|
||||
Returns:
|
||||
List of DeviceInfo objects.
|
||||
"""
|
||||
conn = XCTestConnection()
|
||||
return conn.list_devices()
|
||||
Reference in New Issue
Block a user