Skip to content

Commit f04983d

Browse files
feat(idb_client): installation + setup
1 parent 4c883f0 commit f04983d

File tree

3 files changed

+178
-0
lines changed

3 files changed

+178
-0
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from functools import wraps
2+
from pathlib import Path
3+
4+
from idb.common.types import HIDButtonType, InstalledAppInfo, TCPAddress
5+
from idb.grpc.client import Client
6+
7+
from minitap.mobile_use.utils.logger import get_logger
8+
9+
logger = get_logger(__name__)
10+
11+
12+
def with_idb_client(func):
13+
"""Decorator that creates a Client.build context and injects it as 'client' parameter."""
14+
15+
@wraps(func)
16+
async def wrapper(self, *args, **kwargs):
17+
async with Client.build(address=self.address, logger=logger.logger) as client:
18+
return await func(self, client, *args, **kwargs)
19+
20+
return wrapper
21+
22+
23+
class IdbClientWrapper:
24+
"""Wrapper around fb-idb client for iOS device automation.
25+
26+
Each method uses the @with_idb_client decorator which:
27+
- Creates a fresh Client.build context per operation
28+
- Injects the client as the first parameter after self
29+
- Ensures proper cleanup after each operation completes
30+
"""
31+
32+
def __init__(self, udid: str, host: str = "localhost", port: int = 10882):
33+
"""
34+
Initialize IDB Controller
35+
36+
Args:
37+
udid: Device UDID
38+
host: IDB companion host (default: localhost)
39+
port: IDB companion port (default: 10882)
40+
"""
41+
self.udid = udid
42+
self.address = TCPAddress(host=host, port=port)
43+
44+
@with_idb_client
45+
async def tap(self, client: Client, x: int, y: int, duration: float | None = None):
46+
await client.tap(x=x, y=y, duration=duration)
47+
48+
@with_idb_client
49+
async def swipe(self, client: Client, x_start: int, y_start: int, x_end: int, y_end: int):
50+
await client.swipe(p_start=(x_start, y_start), p_end=(x_end, y_end), duration=0.5)
51+
52+
@with_idb_client
53+
async def screenshot(self, client: Client, output_path: str | None = None):
54+
screenshot_data = await client.screenshot()
55+
if output_path:
56+
with open(output_path, "wb") as f:
57+
f.write(screenshot_data)
58+
return screenshot_data
59+
60+
@with_idb_client
61+
async def launch(
62+
self,
63+
client: Client,
64+
bundle_id: str,
65+
args: list[str] | None = None,
66+
env: dict[str, str] | None = None,
67+
) -> bool:
68+
try:
69+
await client.launch(
70+
bundle_id=bundle_id, args=args or [], env=env or {}, foreground_if_running=True
71+
)
72+
return True
73+
except Exception as e:
74+
logger.error(f"Failed to launch: {e}")
75+
return False
76+
77+
@with_idb_client
78+
async def terminate(self, client: Client, bundle_id: str) -> bool:
79+
try:
80+
await client.terminate(bundle_id)
81+
return True
82+
except Exception as e:
83+
logger.error(f"Failed to terminate: {e}")
84+
return False
85+
86+
@with_idb_client
87+
async def install(self, client: Client, app_path: str) -> bool:
88+
try:
89+
bundle_path = Path(app_path)
90+
with open(bundle_path, "rb") as f:
91+
async for _ in client.install(bundle=f):
92+
pass # Consume the async iterator
93+
return True
94+
except Exception as e:
95+
logger.error(f"Failed to install: {e}")
96+
return False
97+
98+
@with_idb_client
99+
async def uninstall(self, client: Client, bundle_id: str) -> bool:
100+
try:
101+
await client.uninstall(bundle_id)
102+
return True
103+
except Exception as e:
104+
logger.error(f"Failed to uninstall: {e}")
105+
return False
106+
107+
@with_idb_client
108+
async def list_apps(self, client: Client) -> list[InstalledAppInfo]:
109+
try:
110+
apps = await client.list_apps()
111+
return apps
112+
except Exception as e:
113+
logger.error(f"Failed to list apps: {e}")
114+
return []
115+
116+
@with_idb_client
117+
async def text(self, client: Client, text: str) -> bool:
118+
try:
119+
await client.text(text)
120+
return True
121+
except Exception as e:
122+
logger.error(f"Failed to type: {e}")
123+
return False
124+
125+
@with_idb_client
126+
async def key(self, client: Client, key_code: int) -> bool:
127+
try:
128+
await client.key(key_code)
129+
return True
130+
except Exception as e:
131+
logger.error(f"Failed to press key: {e}")
132+
return False
133+
134+
@with_idb_client
135+
async def button(self, client: Client, button_type: str) -> bool:
136+
try:
137+
button_map = {
138+
"HOME": HIDButtonType.HOME,
139+
"LOCK": HIDButtonType.LOCK,
140+
"SIDE_BUTTON": HIDButtonType.SIDE_BUTTON,
141+
"APPLE_PAY": HIDButtonType.APPLE_PAY,
142+
"SIRI": HIDButtonType.SIRI,
143+
}
144+
button_enum = button_map.get(button_type.upper())
145+
if not button_enum:
146+
return False
147+
148+
await client.button(button_type=button_enum)
149+
return True
150+
except Exception as e:
151+
logger.error(f"Failed to press button: {e}")
152+
return False
153+
154+
@with_idb_client
155+
async def clear_keychain(self, client: Client) -> bool:
156+
try:
157+
await client.clear_keychain()
158+
return True
159+
except Exception as e:
160+
logger.error(f"Failed to clear keychain: {e}")
161+
return False
162+
163+
@with_idb_client
164+
async def open_url(self, client: Client, url: str) -> bool:
165+
try:
166+
await client.open_url(url)
167+
return True
168+
except Exception as e:
169+
logger.error(f"Failed to open URL: {e}")
170+
return False

minitap/mobile_use/context.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from minitap.mobile_use.agents.planner.types import Subgoal
1717
from minitap.mobile_use.clients.device_hardware_client import DeviceHardwareClient
18+
from minitap.mobile_use.clients.idb_client import IdbClientWrapper
1819
from minitap.mobile_use.clients.screen_api_client import ScreenApiClient
1920
from minitap.mobile_use.config import AgentNode, LLMConfig
2021

@@ -83,6 +84,7 @@ class MobileUseContext(BaseModel):
8384
screen_api_client: ScreenApiClient
8485
llm_config: LLMConfig
8586
adb_client: AdbClient | None = None
87+
idb_client: IdbClientWrapper | None = None
8688
execution_setup: ExecutionSetup | None = None
8789
on_agent_thought: Callable[[AgentNode, str], Coroutine] | None = None
8890
on_plan_changes: Callable[[list[Subgoal], IsReplan], Coroutine] | None = None
@@ -92,3 +94,8 @@ def get_adb_client(self) -> AdbClient:
9294
if self.adb_client is None:
9395
raise ValueError("No ADB client in context.")
9496
return self.adb_client # type: ignore
97+
98+
def get_idb_client(self) -> IdbClientWrapper:
99+
if self.idb_client is None:
100+
raise ValueError("No IDB client in context.")
101+
return self.idb_client # type: ignore

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434
"psutil>=5.9.0",
3535
"langchain-google-vertexai>=2.0.28",
3636
"httpx>=0.28.1",
37+
"fb-idb==1.1.7",
3738
]
3839

3940
[project.optional-dependencies]

0 commit comments

Comments
 (0)