mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-02-28 00:22:41 -08:00
update
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user