mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-02-28 12:22:43 -08:00
433 lines
14 KiB
Python
433 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Shared device and simulator utilities.
|
|
|
|
Common patterns for interacting with simulators via xcrun simctl and IDB.
|
|
Standardizes command building and device targeting to prevent errors.
|
|
|
|
Follows Jackson's Law - only extracts genuinely reused patterns.
|
|
|
|
Used by:
|
|
- app_launcher.py (8 call sites) - App lifecycle commands
|
|
- Multiple scripts (15+ locations) - IDB command building
|
|
- navigator.py, gesture.py - Coordinate transformation
|
|
- test_recorder.py, app_state_capture.py - Auto-UDID detection
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
|
|
|
|
def build_simctl_command(
|
|
operation: str,
|
|
udid: str | None = None,
|
|
*args,
|
|
) -> list[str]:
|
|
"""
|
|
Build xcrun simctl command with proper device handling.
|
|
|
|
Standardizes command building to prevent device targeting bugs.
|
|
Automatically uses "booted" if no UDID provided.
|
|
|
|
Used by:
|
|
- app_launcher.py: launch, terminate, install, uninstall, openurl, listapps, spawn
|
|
- Multiple scripts: generic simctl operations
|
|
|
|
Args:
|
|
operation: simctl operation (launch, terminate, install, etc.)
|
|
udid: Device UDID (uses 'booted' if None)
|
|
*args: Additional command arguments
|
|
|
|
Returns:
|
|
Complete command list ready for subprocess.run()
|
|
|
|
Examples:
|
|
# Launch app on booted simulator
|
|
cmd = build_simctl_command("launch", None, "com.app.bundle")
|
|
# Returns: ["xcrun", "simctl", "launch", "booted", "com.app.bundle"]
|
|
|
|
# Launch on specific device
|
|
cmd = build_simctl_command("launch", "ABC123", "com.app.bundle")
|
|
# Returns: ["xcrun", "simctl", "launch", "ABC123", "com.app.bundle"]
|
|
|
|
# Install app on specific device
|
|
cmd = build_simctl_command("install", "ABC123", "/path/to/app.app")
|
|
# Returns: ["xcrun", "simctl", "install", "ABC123", "/path/to/app.app"]
|
|
"""
|
|
cmd = ["xcrun", "simctl", operation]
|
|
|
|
# Add device (booted or specific UDID)
|
|
cmd.append(udid if udid else "booted")
|
|
|
|
# Add remaining arguments
|
|
cmd.extend(str(arg) for arg in args)
|
|
|
|
return cmd
|
|
|
|
|
|
def build_idb_command(
|
|
operation: str,
|
|
udid: str | None = None,
|
|
*args,
|
|
) -> list[str]:
|
|
"""
|
|
Build IDB command with proper device targeting.
|
|
|
|
Standardizes IDB command building across all scripts using IDB.
|
|
Handles device UDID consistently.
|
|
|
|
Used by:
|
|
- navigator.py: ui tap, ui text, ui describe-all
|
|
- gesture.py: ui swipe, ui tap
|
|
- keyboard.py: ui key, ui text, ui tap
|
|
- And more: 15+ locations
|
|
|
|
Args:
|
|
operation: IDB operation path (e.g., "ui tap", "ui text", "ui describe-all")
|
|
udid: Device UDID (omits --udid flag if None, IDB uses booted by default)
|
|
*args: Additional command arguments
|
|
|
|
Returns:
|
|
Complete command list ready for subprocess.run()
|
|
|
|
Examples:
|
|
# Tap on booted simulator
|
|
cmd = build_idb_command("ui tap", None, "200", "400")
|
|
# Returns: ["idb", "ui", "tap", "200", "400"]
|
|
|
|
# Tap on specific device
|
|
cmd = build_idb_command("ui tap", "ABC123", "200", "400")
|
|
# Returns: ["idb", "ui", "tap", "200", "400", "--udid", "ABC123"]
|
|
|
|
# Get accessibility tree
|
|
cmd = build_idb_command("ui describe-all", "ABC123", "--json", "--nested")
|
|
# Returns: ["idb", "ui", "describe-all", "--json", "--nested", "--udid", "ABC123"]
|
|
|
|
# Enter text
|
|
cmd = build_idb_command("ui text", None, "hello world")
|
|
# Returns: ["idb", "ui", "text", "hello world"]
|
|
"""
|
|
# Split operation into parts (e.g., "ui tap" -> ["ui", "tap"])
|
|
cmd = ["idb"] + operation.split()
|
|
|
|
# Add arguments
|
|
cmd.extend(str(arg) for arg in args)
|
|
|
|
# Add device targeting if specified (optional for IDB, uses booted by default)
|
|
if udid:
|
|
cmd.extend(["--udid", udid])
|
|
|
|
return cmd
|
|
|
|
|
|
def get_booted_device_udid() -> str | None:
|
|
"""
|
|
Auto-detect currently booted simulator UDID.
|
|
|
|
Queries xcrun simctl for booted devices and returns first match.
|
|
|
|
Returns:
|
|
UDID of booted simulator, or None if no simulator is booted.
|
|
|
|
Example:
|
|
udid = get_booted_device_udid()
|
|
if udid:
|
|
print(f"Booted simulator: {udid}")
|
|
else:
|
|
print("No simulator is currently booted")
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["xcrun", "simctl", "list", "devices", "booted"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
|
|
# Parse output to find UDID
|
|
# Format: " iPhone 16 Pro (ABC123-DEF456) (Booted)"
|
|
for line in result.stdout.split("\n"):
|
|
# Look for UUID pattern in parentheses
|
|
match = re.search(r"\(([A-F0-9\-]{36})\)", line)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
return None
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
|
|
|
|
def resolve_udid(udid_arg: str | None) -> str:
|
|
"""
|
|
Resolve device UDID with auto-detection fallback.
|
|
|
|
If udid_arg is provided, returns it immediately.
|
|
If None, attempts to auto-detect booted simulator.
|
|
Raises error if neither is available.
|
|
|
|
Args:
|
|
udid_arg: Explicit UDID from command line, or None
|
|
|
|
Returns:
|
|
Valid UDID string
|
|
|
|
Raises:
|
|
RuntimeError: If no UDID provided and no booted simulator found
|
|
|
|
Example:
|
|
try:
|
|
udid = resolve_udid(args.udid) # args.udid might be None
|
|
print(f"Using device: {udid}")
|
|
except RuntimeError as e:
|
|
print(f"Error: {e}")
|
|
sys.exit(1)
|
|
"""
|
|
if udid_arg:
|
|
return udid_arg
|
|
|
|
booted_udid = get_booted_device_udid()
|
|
if booted_udid:
|
|
return booted_udid
|
|
|
|
raise RuntimeError(
|
|
"No device UDID provided and no simulator is currently booted.\n"
|
|
"Boot a simulator or provide --udid explicitly:\n"
|
|
" xcrun simctl boot <device-name>\n"
|
|
" python scripts/script_name.py --udid <device-udid>"
|
|
)
|
|
|
|
|
|
def get_device_screen_size(udid: str) -> tuple[int, int]:
|
|
"""
|
|
Get actual screen dimensions for device via accessibility tree.
|
|
|
|
Queries IDB accessibility tree to determine actual device resolution.
|
|
Falls back to iPhone 14 defaults (390x844) if detection fails.
|
|
|
|
Args:
|
|
udid: Device UDID
|
|
|
|
Returns:
|
|
Tuple of (width, height) in pixels
|
|
|
|
Example:
|
|
width, height = get_device_screen_size("ABC123")
|
|
print(f"Device screen: {width}x{height}")
|
|
"""
|
|
try:
|
|
cmd = build_idb_command("ui describe-all", udid, "--json")
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
|
|
# Parse JSON response
|
|
data = json.loads(result.stdout)
|
|
tree = data[0] if isinstance(data, list) and len(data) > 0 else data
|
|
|
|
# Get frame size from root element
|
|
if tree and "frame" in tree:
|
|
frame = tree["frame"]
|
|
width = int(frame.get("width", 390))
|
|
height = int(frame.get("height", 844))
|
|
return (width, height)
|
|
|
|
# Fallback
|
|
return (390, 844)
|
|
except Exception:
|
|
# Graceful fallback to iPhone 14 Pro defaults
|
|
return (390, 844)
|
|
|
|
|
|
def resolve_device_identifier(identifier: str) -> str:
|
|
"""
|
|
Resolve device name or partial UDID to full UDID.
|
|
|
|
Supports multiple identifier formats:
|
|
- Full UDID: "ABC-123-DEF456..." (36 character UUID)
|
|
- Device name: "iPhone 16 Pro" (matches full name)
|
|
- Partial match: "iPhone 16" (matches first device containing this string)
|
|
- Special: "booted" (resolves to currently booted device)
|
|
|
|
Args:
|
|
identifier: Device UDID, name, or special value "booted"
|
|
|
|
Returns:
|
|
Full device UDID
|
|
|
|
Raises:
|
|
RuntimeError: If identifier cannot be resolved
|
|
|
|
Example:
|
|
udid = resolve_device_identifier("iPhone 16 Pro")
|
|
# Returns: "ABC123DEF456..."
|
|
|
|
udid = resolve_device_identifier("booted")
|
|
# Returns UDID of booted simulator
|
|
"""
|
|
# Handle "booted" special case
|
|
if identifier.lower() == "booted":
|
|
booted = get_booted_device_udid()
|
|
if booted:
|
|
return booted
|
|
raise RuntimeError(
|
|
"No simulator is currently booted. "
|
|
"Boot a simulator first: xcrun simctl boot <device-udid>"
|
|
)
|
|
|
|
# Check if already a full UDID (36 character UUID format)
|
|
if re.match(r"^[A-F0-9\-]{36}$", identifier, re.IGNORECASE):
|
|
return identifier.upper()
|
|
|
|
# Try to match by device name
|
|
simulators = list_simulators(state=None)
|
|
exact_matches = [s for s in simulators if s["name"].lower() == identifier.lower()]
|
|
if exact_matches:
|
|
return exact_matches[0]["udid"]
|
|
|
|
# Try partial match
|
|
partial_matches = [s for s in simulators if identifier.lower() in s["name"].lower()]
|
|
if partial_matches:
|
|
return partial_matches[0]["udid"]
|
|
|
|
# No match found
|
|
raise RuntimeError(
|
|
f"Device '{identifier}' not found. "
|
|
f"Use 'xcrun simctl list devices' to see available simulators."
|
|
)
|
|
|
|
|
|
def list_simulators(state: str | None = None) -> list[dict]:
|
|
"""
|
|
List iOS simulators with optional state filtering.
|
|
|
|
Queries xcrun simctl and returns structured list of simulators.
|
|
Optionally filters by state (available, booted, all).
|
|
|
|
Args:
|
|
state: Optional filter - "available", "booted", or None for all
|
|
|
|
Returns:
|
|
List of simulator dicts with keys:
|
|
- "name": Device name (e.g., "iPhone 16 Pro")
|
|
- "udid": Device UDID (36 char UUID)
|
|
- "state": Device state ("Booted", "Shutdown", "Unavailable")
|
|
- "runtime": iOS version (e.g., "iOS 18.0", "unavailable")
|
|
- "type": Device type ("iPhone", "iPad", "Apple Watch", etc.)
|
|
|
|
Example:
|
|
# List all simulators
|
|
all_sims = list_simulators()
|
|
print(f"Total simulators: {len(all_sims)}")
|
|
|
|
# List only available simulators
|
|
available = list_simulators(state="available")
|
|
for sim in available:
|
|
print(f"{sim['name']} ({sim['state']}) - {sim['udid']}")
|
|
|
|
# List only booted simulators
|
|
booted = list_simulators(state="booted")
|
|
for sim in booted:
|
|
print(f"Booted: {sim['name']}")
|
|
"""
|
|
try:
|
|
# Query simctl for device list
|
|
cmd = ["xcrun", "simctl", "list", "devices", "-j"]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
|
|
data = json.loads(result.stdout)
|
|
simulators = []
|
|
|
|
# Parse JSON response
|
|
# Format: {"devices": {"iOS 18.0": [{...}, {...}], "iOS 17.0": [...], ...}}
|
|
for ios_version, devices in data.get("devices", {}).items():
|
|
for device in devices:
|
|
sim = {
|
|
"name": device.get("name", "Unknown"),
|
|
"udid": device.get("udid", ""),
|
|
"state": device.get("state", "Unknown"),
|
|
"runtime": ios_version,
|
|
"type": _extract_device_type(device.get("name", "")),
|
|
}
|
|
simulators.append(sim)
|
|
|
|
# Apply state filtering
|
|
if state == "booted":
|
|
return [s for s in simulators if s["state"] == "Booted"]
|
|
if state == "available":
|
|
return [s for s in simulators if s["state"] == "Shutdown"] # Available to boot
|
|
if state is None:
|
|
return simulators
|
|
return [s for s in simulators if s["state"].lower() == state.lower()]
|
|
|
|
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
|
|
raise RuntimeError(f"Failed to list simulators: {e}") from e
|
|
|
|
|
|
def _extract_device_type(device_name: str) -> str:
|
|
"""
|
|
Extract device type from device name.
|
|
|
|
Parses device name to determine type (iPhone, iPad, Watch, etc.).
|
|
|
|
Args:
|
|
device_name: Full device name (e.g., "iPhone 16 Pro")
|
|
|
|
Returns:
|
|
Device type string
|
|
|
|
Example:
|
|
_extract_device_type("iPhone 16 Pro") # Returns "iPhone"
|
|
_extract_device_type("iPad Air") # Returns "iPad"
|
|
_extract_device_type("Apple Watch Series 9") # Returns "Watch"
|
|
"""
|
|
if "iPhone" in device_name:
|
|
return "iPhone"
|
|
if "iPad" in device_name:
|
|
return "iPad"
|
|
if "Watch" in device_name or "Apple Watch" in device_name:
|
|
return "Watch"
|
|
if "TV" in device_name or "Apple TV" in device_name:
|
|
return "TV"
|
|
return "Unknown"
|
|
|
|
|
|
def transform_screenshot_coords(
|
|
x: float,
|
|
y: float,
|
|
screenshot_width: int,
|
|
screenshot_height: int,
|
|
device_width: int,
|
|
device_height: int,
|
|
) -> tuple[int, int]:
|
|
"""
|
|
Transform screenshot coordinates to device coordinates.
|
|
|
|
Handles the case where a screenshot was downscaled (e.g., to 'half' size)
|
|
and needs to be transformed back to actual device pixel coordinates
|
|
for accurate tapping.
|
|
|
|
The transformation is linear:
|
|
device_x = (screenshot_x / screenshot_width) * device_width
|
|
device_y = (screenshot_y / screenshot_height) * device_height
|
|
|
|
Args:
|
|
x, y: Coordinates in the screenshot
|
|
screenshot_width, screenshot_height: Screenshot dimensions (e.g., 195, 422)
|
|
device_width, device_height: Actual device dimensions (e.g., 390, 844)
|
|
|
|
Returns:
|
|
Tuple of (device_x, device_y) in device pixels
|
|
|
|
Example:
|
|
# Screenshot taken at 'half' size: 195x422 (from 390x844 device)
|
|
device_x, device_y = transform_screenshot_coords(
|
|
100, 200, # Tap point in screenshot
|
|
195, 422, # Screenshot dimensions
|
|
390, 844 # Device dimensions
|
|
)
|
|
print(f"Tap at device coords: ({device_x}, {device_y})")
|
|
# Output: Tap at device coords: (200, 400)
|
|
"""
|
|
device_x = int((x / screenshot_width) * device_width)
|
|
device_y = int((y / screenshot_height) * device_height)
|
|
return (device_x, device_y)
|