Skip to content

Commit c7ea43a

Browse files
feat(idb_client): manage idb_companion lifecycle if no host is provided
1 parent bad4866 commit c7ea43a

File tree

1 file changed

+137
-12
lines changed

1 file changed

+137
-12
lines changed

minitap/mobile_use/clients/idb_client.py

Lines changed: 137 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import asyncio
12
import json
3+
import socket
4+
import subprocess
25
from functools import wraps
36
from pathlib import Path
47
from typing import Any
@@ -11,6 +14,20 @@
1114
logger = get_logger(__name__)
1215

1316

17+
def _find_available_port(start_port: int = 10882, max_attempts: int = 100) -> int:
18+
"""Find an available port starting from start_port."""
19+
for port in range(start_port, start_port + max_attempts):
20+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
21+
try:
22+
s.bind(("localhost", port))
23+
return port
24+
except OSError:
25+
continue
26+
raise RuntimeError(
27+
f"Could not find available port in range {start_port}-{start_port + max_attempts}"
28+
)
29+
30+
1431
def with_idb_client(func):
1532
"""Decorator that creates a Client.build context and injects it as 'client' parameter."""
1633

@@ -23,25 +40,133 @@ async def wrapper(self, *args, **kwargs):
2340

2441

2542
class IdbClientWrapper:
26-
"""Wrapper around fb-idb client for iOS device automation.
43+
"""Wrapper around fb-idb client for iOS device automation with lifecycle management.
44+
45+
This wrapper can either manage the idb_companion process lifecycle locally or connect
46+
to an external companion server.
47+
48+
Lifecycle Management:
49+
- If host is None (default): Manages companion locally on localhost
50+
- Call init_companion() to start the idb_companion process
51+
- Call cleanup() to stop the companion process
52+
- Or use as async context manager for automatic lifecycle
53+
- If host is provided: Connects to external companion server
54+
- init_companion() and cleanup() become no-ops
55+
- You manage the external companion separately
56+
57+
Example:
58+
# Managed companion (recommended for local development)
59+
async with IdbClientWrapper(udid="device-id") as wrapper:
60+
await wrapper.tap(100, 200)
2761
28-
Each method uses the @with_idb_client decorator which:
29-
- Creates a fresh Client.build context per operation
30-
- Injects the client as the first parameter after self
31-
- Ensures proper cleanup after each operation completes
62+
# External companion (for production/remote)
63+
wrapper = IdbClientWrapper(udid="device-id", host="remote-host", port=10882)
64+
await wrapper.tap(100, 200) # No companion lifecycle management needed
3265
"""
3366

34-
def __init__(self, udid: str, host: str = "localhost", port: int = 10882):
67+
def __init__(self, udid: str, host: str | None = None, port: int | None = None):
68+
self.udid = udid
69+
self._manage_companion = host is None
70+
71+
if host is None:
72+
actual_port = port if port is not None else _find_available_port()
73+
self.address = TCPAddress(host="localhost", port=actual_port)
74+
logger.debug(f"Will manage companion for {udid} on port {actual_port}")
75+
else:
76+
actual_port = port if port is not None else 10882
77+
self.address = TCPAddress(host=host, port=actual_port)
78+
79+
self.companion_process: subprocess.Popen | None = None
80+
81+
async def init_companion(self, idb_companion_path: str = "idb_companion") -> bool:
3582
"""
36-
Initialize IDB Controller
83+
Start the idb_companion process for this device.
84+
Only starts if managing companion locally (host was None in __init__).
3785
3886
Args:
39-
udid: Device UDID
40-
host: IDB companion host (default: localhost)
41-
port: IDB companion port (default: 10882)
87+
idb_companion_path: Path to idb_companion binary (default: "idb_companion" from PATH)
88+
89+
Returns:
90+
True if companion started successfully, False otherwise
4291
"""
43-
self.udid = udid
44-
self.address = TCPAddress(host=host, port=port)
92+
if not self._manage_companion:
93+
logger.info(f"Using external idb_companion at {self.address.host}:{self.address.port}")
94+
return True
95+
96+
if self.companion_process is not None:
97+
logger.warning(f"idb_companion already running for {self.udid}")
98+
return True
99+
100+
try:
101+
cmd = [idb_companion_path, "--udid", self.udid, "--grpc-port", str(self.address.port)]
102+
103+
logger.info(f"Starting idb_companion: {' '.join(cmd)}")
104+
self.companion_process = subprocess.Popen(
105+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
106+
)
107+
108+
await asyncio.sleep(2)
109+
110+
if self.companion_process.poll() is not None:
111+
stdout, stderr = self.companion_process.communicate()
112+
logger.error(f"idb_companion failed to start: {stderr}")
113+
self.companion_process = None
114+
return False
115+
116+
logger.info(
117+
f"idb_companion started successfully for {self.udid} on port {self.address.port}"
118+
)
119+
return True
120+
121+
except Exception as e:
122+
logger.error(f"Failed to start idb_companion: {e}")
123+
self.companion_process = None
124+
return False
125+
126+
async def cleanup(self) -> None:
127+
if not self._manage_companion:
128+
logger.debug(f"Not managing companion for {self.udid}, skipping cleanup")
129+
return
130+
131+
if self.companion_process is None:
132+
return
133+
134+
try:
135+
logger.info(f"Stopping idb_companion for {self.udid}")
136+
137+
self.companion_process.terminate()
138+
139+
try:
140+
await asyncio.wait_for(asyncio.to_thread(self.companion_process.wait), timeout=5.0)
141+
logger.info(f"idb_companion stopped gracefully for {self.udid}")
142+
except TimeoutError:
143+
logger.warning(f"Force killing idb_companion for {self.udid}")
144+
self.companion_process.kill()
145+
await asyncio.to_thread(self.companion_process.wait)
146+
147+
except Exception as e:
148+
logger.error(f"Error stopping idb_companion: {e}")
149+
finally:
150+
self.companion_process = None
151+
152+
def __del__(self):
153+
if self.companion_process is not None:
154+
try:
155+
self.companion_process.terminate()
156+
self.companion_process.wait(timeout=2)
157+
except Exception:
158+
try:
159+
self.companion_process.kill()
160+
except Exception:
161+
pass
162+
163+
async def __aenter__(self):
164+
await self.init_companion()
165+
return self
166+
167+
async def __aexit__(self, exc_type, exc_val, exc_tb):
168+
await self.cleanup()
169+
return False
45170

46171
@with_idb_client
47172
async def tap(self, client: Client, x: int, y: int, duration: float | None = None):

0 commit comments

Comments
 (0)