1+ import asyncio
12import json
3+ import socket
4+ import subprocess
25from functools import wraps
36from pathlib import Path
47from typing import Any
1114logger = 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+
1431def 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
2542class 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