minitap-mobile-use 3.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minitap/mobile_use/__init__.py +0 -0
- minitap/mobile_use/agents/contextor/contextor.md +55 -0
- minitap/mobile_use/agents/contextor/contextor.py +175 -0
- minitap/mobile_use/agents/contextor/types.py +36 -0
- minitap/mobile_use/agents/cortex/cortex.md +135 -0
- minitap/mobile_use/agents/cortex/cortex.py +152 -0
- minitap/mobile_use/agents/cortex/types.py +15 -0
- minitap/mobile_use/agents/executor/executor.md +42 -0
- minitap/mobile_use/agents/executor/executor.py +87 -0
- minitap/mobile_use/agents/executor/tool_node.py +152 -0
- minitap/mobile_use/agents/hopper/hopper.md +15 -0
- minitap/mobile_use/agents/hopper/hopper.py +44 -0
- minitap/mobile_use/agents/orchestrator/human.md +12 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
- minitap/mobile_use/agents/orchestrator/types.py +11 -0
- minitap/mobile_use/agents/outputter/human.md +25 -0
- minitap/mobile_use/agents/outputter/outputter.py +85 -0
- minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
- minitap/mobile_use/agents/planner/human.md +14 -0
- minitap/mobile_use/agents/planner/planner.md +126 -0
- minitap/mobile_use/agents/planner/planner.py +101 -0
- minitap/mobile_use/agents/planner/types.py +51 -0
- minitap/mobile_use/agents/planner/utils.py +70 -0
- minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
- minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
- minitap/mobile_use/agents/video_analyzer/human.md +5 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
- minitap/mobile_use/clients/browserstack_client.py +477 -0
- minitap/mobile_use/clients/idb_client.py +429 -0
- minitap/mobile_use/clients/ios_client.py +332 -0
- minitap/mobile_use/clients/ios_client_config.py +141 -0
- minitap/mobile_use/clients/ui_automator_client.py +330 -0
- minitap/mobile_use/clients/wda_client.py +526 -0
- minitap/mobile_use/clients/wda_lifecycle.py +367 -0
- minitap/mobile_use/config.py +413 -0
- minitap/mobile_use/constants.py +3 -0
- minitap/mobile_use/context.py +106 -0
- minitap/mobile_use/controllers/__init__.py +0 -0
- minitap/mobile_use/controllers/android_controller.py +524 -0
- minitap/mobile_use/controllers/controller_factory.py +46 -0
- minitap/mobile_use/controllers/device_controller.py +182 -0
- minitap/mobile_use/controllers/ios_controller.py +436 -0
- minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
- minitap/mobile_use/controllers/types.py +106 -0
- minitap/mobile_use/controllers/unified_controller.py +193 -0
- minitap/mobile_use/graph/graph.py +160 -0
- minitap/mobile_use/graph/state.py +115 -0
- minitap/mobile_use/main.py +309 -0
- minitap/mobile_use/sdk/__init__.py +12 -0
- minitap/mobile_use/sdk/agent.py +1294 -0
- minitap/mobile_use/sdk/builders/__init__.py +10 -0
- minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
- minitap/mobile_use/sdk/builders/index.py +15 -0
- minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
- minitap/mobile_use/sdk/constants.py +1 -0
- minitap/mobile_use/sdk/examples/README.md +83 -0
- minitap/mobile_use/sdk/examples/__init__.py +1 -0
- minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
- minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
- minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
- minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
- minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
- minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
- minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
- minitap/mobile_use/sdk/services/platform.py +434 -0
- minitap/mobile_use/sdk/types/__init__.py +51 -0
- minitap/mobile_use/sdk/types/agent.py +84 -0
- minitap/mobile_use/sdk/types/exceptions.py +138 -0
- minitap/mobile_use/sdk/types/platform.py +183 -0
- minitap/mobile_use/sdk/types/task.py +269 -0
- minitap/mobile_use/sdk/utils.py +29 -0
- minitap/mobile_use/services/accessibility.py +100 -0
- minitap/mobile_use/services/llm.py +247 -0
- minitap/mobile_use/services/telemetry.py +421 -0
- minitap/mobile_use/tools/index.py +67 -0
- minitap/mobile_use/tools/mobile/back.py +52 -0
- minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
- minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
- minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
- minitap/mobile_use/tools/mobile/launch_app.py +86 -0
- minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
- minitap/mobile_use/tools/mobile/open_link.py +62 -0
- minitap/mobile_use/tools/mobile/press_key.py +83 -0
- minitap/mobile_use/tools/mobile/stop_app.py +62 -0
- minitap/mobile_use/tools/mobile/swipe.py +156 -0
- minitap/mobile_use/tools/mobile/tap.py +154 -0
- minitap/mobile_use/tools/mobile/video_recording.py +177 -0
- minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
- minitap/mobile_use/tools/scratchpad.py +147 -0
- minitap/mobile_use/tools/test_utils.py +413 -0
- minitap/mobile_use/tools/tool_wrapper.py +16 -0
- minitap/mobile_use/tools/types.py +35 -0
- minitap/mobile_use/tools/utils.py +336 -0
- minitap/mobile_use/utils/app_launch_utils.py +173 -0
- minitap/mobile_use/utils/cli_helpers.py +37 -0
- minitap/mobile_use/utils/cli_selection.py +143 -0
- minitap/mobile_use/utils/conversations.py +31 -0
- minitap/mobile_use/utils/decorators.py +124 -0
- minitap/mobile_use/utils/errors.py +6 -0
- minitap/mobile_use/utils/file.py +13 -0
- minitap/mobile_use/utils/logger.py +183 -0
- minitap/mobile_use/utils/media.py +186 -0
- minitap/mobile_use/utils/recorder.py +52 -0
- minitap/mobile_use/utils/requests_utils.py +37 -0
- minitap/mobile_use/utils/shell_utils.py +20 -0
- minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
- minitap/mobile_use/utils/time.py +6 -0
- minitap/mobile_use/utils/ui_hierarchy.py +132 -0
- minitap/mobile_use/utils/video.py +281 -0
- minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
- minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
- minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
- minitap_mobile_use-3.3.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import socket
|
|
4
|
+
import subprocess
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from idb.common.types import HIDButtonType, InstalledAppInfo, InstalledArtifact, TCPAddress
|
|
10
|
+
from idb.grpc.client import Client
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IOSAppInfo(BaseModel):
|
|
17
|
+
name: str | None
|
|
18
|
+
bundle_id: str | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_available_port(start_port: int = 10882, max_attempts: int = 100) -> int:
|
|
25
|
+
for port in range(start_port, start_port + max_attempts):
|
|
26
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
27
|
+
try:
|
|
28
|
+
s.bind(("localhost", port))
|
|
29
|
+
return port
|
|
30
|
+
except OSError:
|
|
31
|
+
continue
|
|
32
|
+
raise RuntimeError(
|
|
33
|
+
f"Could not find available port in range {start_port}-{start_port + max_attempts}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def with_idb_client(func):
|
|
38
|
+
"""Decorator to ensure idb client is initialized before method call.
|
|
39
|
+
|
|
40
|
+
Note: Function must have None or bool in return type.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@wraps(func)
|
|
44
|
+
async def wrapper(self, *args, **kwargs):
|
|
45
|
+
method_name = func.__name__
|
|
46
|
+
try:
|
|
47
|
+
if self._client is None:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
"IDB client not initialized. "
|
|
50
|
+
"Use 'async with' context manager or call init_companion() first."
|
|
51
|
+
)
|
|
52
|
+
logger.debug(f"Calling {method_name}...")
|
|
53
|
+
result = await func(self, *args, **kwargs)
|
|
54
|
+
logger.debug(f"{method_name} completed successfully")
|
|
55
|
+
return result
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error(f"Failed to {method_name}: {e}")
|
|
58
|
+
import traceback
|
|
59
|
+
|
|
60
|
+
logger.debug(f"Traceback: {traceback.format_exc()}")
|
|
61
|
+
|
|
62
|
+
return_type = func.__annotations__.get("return")
|
|
63
|
+
if return_type is bool:
|
|
64
|
+
return False
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
return wrapper
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IdbClientWrapper:
|
|
71
|
+
"""Wrapper around fb-idb client for iOS device automation with lifecycle management.
|
|
72
|
+
|
|
73
|
+
This wrapper can either manage the idb_companion process lifecycle locally or connect
|
|
74
|
+
to an external companion server.
|
|
75
|
+
|
|
76
|
+
Lifecycle Management:
|
|
77
|
+
- If host is None (default): Manages companion locally on localhost
|
|
78
|
+
- Call init_companion() to start the idb_companion process
|
|
79
|
+
- Call cleanup() to stop the companion process
|
|
80
|
+
- Or use as async context manager for automatic lifecycle
|
|
81
|
+
- If host is provided: Connects to external companion server
|
|
82
|
+
- init_companion() and cleanup() become no-ops
|
|
83
|
+
- You manage the external companion separately
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
# Managed companion (recommended for local development)
|
|
87
|
+
async with IdbClientWrapper(udid="device-id") as wrapper:
|
|
88
|
+
await wrapper.tap(100, 200)
|
|
89
|
+
|
|
90
|
+
# External companion (for production/remote)
|
|
91
|
+
wrapper = IdbClientWrapper(udid="device-id", host="remote-host", port=10882)
|
|
92
|
+
await wrapper.tap(100, 200) # No companion lifecycle management needed
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, udid: str, host: str | None = None, port: int | None = None):
|
|
96
|
+
self.udid = udid
|
|
97
|
+
self._manage_companion = host is None
|
|
98
|
+
|
|
99
|
+
if host is None:
|
|
100
|
+
actual_port = port if port is not None else _find_available_port()
|
|
101
|
+
self.address = TCPAddress(host="localhost", port=actual_port)
|
|
102
|
+
logger.debug(f"Will manage companion for {udid} on port {actual_port}")
|
|
103
|
+
else:
|
|
104
|
+
actual_port = port if port is not None else 10882
|
|
105
|
+
self.address = TCPAddress(host=host, port=actual_port)
|
|
106
|
+
|
|
107
|
+
self.companion_process: subprocess.Popen | None = None
|
|
108
|
+
self._client: Client | None = None
|
|
109
|
+
self._client_generator: Any = None
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def client(self) -> Client:
|
|
113
|
+
"""Get the initialized IDB client. Raises if not initialized."""
|
|
114
|
+
if self._client is None:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
"IDB client not initialized. "
|
|
117
|
+
"Use 'async with' context manager or call init_companion() first."
|
|
118
|
+
)
|
|
119
|
+
return self._client
|
|
120
|
+
|
|
121
|
+
async def init_companion(self, idb_companion_path: str = "idb_companion") -> bool:
|
|
122
|
+
"""
|
|
123
|
+
Start the idb_companion process for this device.
|
|
124
|
+
Only starts if managing companion locally (host was None in __init__).
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
idb_companion_path: Path to idb_companion binary (default: "idb_companion" from PATH)
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True if companion started successfully, False otherwise
|
|
131
|
+
"""
|
|
132
|
+
if not self._manage_companion:
|
|
133
|
+
logger.info(f"Using external idb_companion at {self.address.host}:{self.address.port}")
|
|
134
|
+
# Still need to build the client connection
|
|
135
|
+
logger.debug("Building IDB client connection...")
|
|
136
|
+
self._client_generator = Client.build(address=self.address, logger=logger.logger)
|
|
137
|
+
self._client = await self._client_generator.__aenter__()
|
|
138
|
+
logger.debug("IDB client connected")
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
if self.companion_process is not None:
|
|
142
|
+
logger.warning(f"idb_companion already running for {self.udid}")
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
cmd = [idb_companion_path, "--udid", self.udid, "--grpc-port", str(self.address.port)]
|
|
147
|
+
|
|
148
|
+
logger.info(f"Starting idb_companion: {' '.join(cmd)}")
|
|
149
|
+
self.companion_process = subprocess.Popen(
|
|
150
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Wait longer for gRPC server to be fully ready
|
|
154
|
+
logger.debug("Waiting for idb_companion gRPC server to be ready...")
|
|
155
|
+
await asyncio.sleep(5)
|
|
156
|
+
|
|
157
|
+
if self.companion_process.poll() is not None:
|
|
158
|
+
stdout, stderr = self.companion_process.communicate()
|
|
159
|
+
logger.error(f"idb_companion failed to start: {stderr}")
|
|
160
|
+
self.companion_process = None
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
logger.info(
|
|
164
|
+
f"idb_companion started successfully for {self.udid} on port {self.address.port}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Build and store the client connection
|
|
168
|
+
logger.debug("Building IDB client connection...")
|
|
169
|
+
self._client_generator = Client.build(address=self.address, logger=logger.logger)
|
|
170
|
+
self._client = await self._client_generator.__aenter__()
|
|
171
|
+
logger.debug("IDB client connected")
|
|
172
|
+
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
except FileNotFoundError:
|
|
176
|
+
logger.error(
|
|
177
|
+
"idb_companion not found. Please install fb-idb to use iOS devices.\n"
|
|
178
|
+
"Installation guide: https://fbidb.io/docs/installation/\n"
|
|
179
|
+
"On macOS with Homebrew: brew install idb-companion"
|
|
180
|
+
)
|
|
181
|
+
self.companion_process = None
|
|
182
|
+
return False
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f"Failed to start idb_companion: {e}")
|
|
185
|
+
self.companion_process = None
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
async def cleanup(self) -> None:
|
|
189
|
+
# Always close the client context manager if it exists
|
|
190
|
+
if self._client_generator is not None:
|
|
191
|
+
try:
|
|
192
|
+
logger.debug("Closing IDB client connection...")
|
|
193
|
+
await self._client_generator.__aexit__(None, None, None)
|
|
194
|
+
logger.debug("IDB client closed")
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.error(f"Error closing IDB client: {e}")
|
|
197
|
+
finally:
|
|
198
|
+
self._client = None
|
|
199
|
+
self._client_generator = None
|
|
200
|
+
|
|
201
|
+
if not self._manage_companion:
|
|
202
|
+
logger.debug(f"Not managing companion for {self.udid}, skipping companion cleanup")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
if self.companion_process is None:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
logger.info(f"Stopping idb_companion for {self.udid}")
|
|
210
|
+
|
|
211
|
+
self.companion_process.terminate()
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
await asyncio.wait_for(asyncio.to_thread(self.companion_process.wait), timeout=5.0)
|
|
215
|
+
logger.info(f"idb_companion stopped gracefully for {self.udid}")
|
|
216
|
+
except TimeoutError:
|
|
217
|
+
logger.warning(f"Force killing idb_companion for {self.udid}")
|
|
218
|
+
self.companion_process.kill()
|
|
219
|
+
await asyncio.to_thread(self.companion_process.wait)
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.error(f"Error stopping idb_companion: {e}")
|
|
223
|
+
finally:
|
|
224
|
+
self.companion_process = None
|
|
225
|
+
|
|
226
|
+
def __del__(self):
|
|
227
|
+
if self.companion_process is not None:
|
|
228
|
+
try:
|
|
229
|
+
self.companion_process.terminate()
|
|
230
|
+
self.companion_process.wait(timeout=2)
|
|
231
|
+
except Exception:
|
|
232
|
+
try:
|
|
233
|
+
self.companion_process.kill()
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
async def __aenter__(self):
|
|
238
|
+
if not await self.init_companion():
|
|
239
|
+
raise RuntimeError(f"Failed to initialize idb_companion for device {self.udid}")
|
|
240
|
+
return self
|
|
241
|
+
|
|
242
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
243
|
+
await self.cleanup()
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
@with_idb_client
|
|
247
|
+
async def tap(self, x: int, y: int, duration: float | None = None) -> bool:
|
|
248
|
+
await self.client.tap(x=x, y=y, duration=duration)
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
@with_idb_client
|
|
252
|
+
async def swipe(
|
|
253
|
+
self,
|
|
254
|
+
x_start: int,
|
|
255
|
+
y_start: int,
|
|
256
|
+
x_end: int,
|
|
257
|
+
y_end: int,
|
|
258
|
+
duration: float | None = None,
|
|
259
|
+
) -> bool:
|
|
260
|
+
await self.client.swipe(p_start=(x_start, y_start), p_end=(x_end, y_end), duration=duration)
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
@with_idb_client
|
|
264
|
+
async def screenshot(self, output_path: str | None = None) -> bytes | None:
|
|
265
|
+
"""
|
|
266
|
+
Take a screenshot and return raw image data.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Raw image data (PNG bytes not base64 encoded)
|
|
270
|
+
"""
|
|
271
|
+
screenshot_data = await self.client.screenshot()
|
|
272
|
+
if output_path:
|
|
273
|
+
with open(output_path, "wb") as f:
|
|
274
|
+
f.write(screenshot_data)
|
|
275
|
+
return screenshot_data
|
|
276
|
+
|
|
277
|
+
@with_idb_client
|
|
278
|
+
async def launch(
|
|
279
|
+
self,
|
|
280
|
+
bundle_id: str,
|
|
281
|
+
args: list[str] | None = None,
|
|
282
|
+
env: dict[str, str] | None = None,
|
|
283
|
+
) -> bool:
|
|
284
|
+
await self.client.launch(
|
|
285
|
+
bundle_id=bundle_id, args=args or [], env=env or {}, foreground_if_running=True
|
|
286
|
+
)
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
@with_idb_client
|
|
290
|
+
async def terminate(self, bundle_id: str) -> bool:
|
|
291
|
+
await self.client.terminate(bundle_id)
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
@with_idb_client
|
|
295
|
+
async def install(self, app_path: str) -> list[InstalledArtifact] | None:
|
|
296
|
+
bundle_path = Path(app_path)
|
|
297
|
+
artifacts = []
|
|
298
|
+
with open(bundle_path, "rb") as f:
|
|
299
|
+
async for artifact in self.client.install(bundle=f):
|
|
300
|
+
artifacts.append(artifact)
|
|
301
|
+
return artifacts
|
|
302
|
+
|
|
303
|
+
@with_idb_client
|
|
304
|
+
async def uninstall(self, bundle_id: str) -> bool:
|
|
305
|
+
await self.client.uninstall(bundle_id)
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
@with_idb_client
|
|
309
|
+
async def list_apps(self) -> list[InstalledAppInfo] | None:
|
|
310
|
+
apps = await self.client.list_apps()
|
|
311
|
+
return apps
|
|
312
|
+
|
|
313
|
+
@with_idb_client
|
|
314
|
+
async def text(self, text: str) -> bool:
|
|
315
|
+
await self.client.text(text)
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
@with_idb_client
|
|
319
|
+
async def key(self, key_code: int) -> bool:
|
|
320
|
+
await self.client.key(key_code)
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
@with_idb_client
|
|
324
|
+
async def button(self, button_type: HIDButtonType) -> bool:
|
|
325
|
+
await self.client.button(button_type=button_type)
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
@with_idb_client
|
|
329
|
+
async def clear_keychain(self) -> bool:
|
|
330
|
+
await self.client.clear_keychain()
|
|
331
|
+
return True
|
|
332
|
+
|
|
333
|
+
@with_idb_client
|
|
334
|
+
async def open_url(self, url: str) -> bool:
|
|
335
|
+
await self.client.open_url(url)
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
async def app_current(self) -> IOSAppInfo | None:
|
|
339
|
+
"""Get information about the currently active app on simulator.
|
|
340
|
+
|
|
341
|
+
Uses idb ui describe-all to find the app name from the UI hierarchy,
|
|
342
|
+
then looks up the bundle ID from simctl listapps.
|
|
343
|
+
Returns dict with bundleId or None.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
# Get the accessibility hierarchy to find the foreground app name
|
|
347
|
+
elements = await self.describe_all()
|
|
348
|
+
if not elements:
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
# Find the Application element - it contains the app name in AXLabel
|
|
352
|
+
app_name = None
|
|
353
|
+
for elem in elements:
|
|
354
|
+
if elem.get("type") == "Application":
|
|
355
|
+
app_name = elem.get("AXLabel") or elem.get("label")
|
|
356
|
+
break
|
|
357
|
+
|
|
358
|
+
if not app_name:
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
# Get installed apps from simctl and find bundle ID by display name
|
|
362
|
+
cmd = ["xcrun", "simctl", "listapps", self.udid]
|
|
363
|
+
process = await asyncio.create_subprocess_exec(
|
|
364
|
+
*cmd,
|
|
365
|
+
stdout=asyncio.subprocess.PIPE,
|
|
366
|
+
stderr=asyncio.subprocess.PIPE,
|
|
367
|
+
)
|
|
368
|
+
stdout, _ = await process.communicate()
|
|
369
|
+
|
|
370
|
+
if process.returncode != 0:
|
|
371
|
+
return IOSAppInfo(name=app_name, bundle_id=None)
|
|
372
|
+
|
|
373
|
+
# Parse plist-style output
|
|
374
|
+
# Format: "com.apple.MobileAddressBook" = { ... CFBundleDisplayName = Contacts; ...}
|
|
375
|
+
import re
|
|
376
|
+
|
|
377
|
+
output = stdout.decode()
|
|
378
|
+
current_bundle_id = None
|
|
379
|
+
|
|
380
|
+
for line in output.split("\n"):
|
|
381
|
+
line = line.strip()
|
|
382
|
+
# Match app entry: "com.bundle.id" = {
|
|
383
|
+
bundle_match = re.match(r'"([^"]+)"\s*=\s*\{', line)
|
|
384
|
+
if bundle_match:
|
|
385
|
+
current_bundle_id = bundle_match.group(1)
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
# Match display name: CFBundleDisplayName = AppName; (no quotes)
|
|
389
|
+
# or CFBundleName = AppName;
|
|
390
|
+
if current_bundle_id:
|
|
391
|
+
name_match = re.match(r"CFBundle(?:Display)?Name\s*=\s*([^;]+);", line)
|
|
392
|
+
if name_match:
|
|
393
|
+
display_name = name_match.group(1).strip()
|
|
394
|
+
if display_name == app_name:
|
|
395
|
+
return IOSAppInfo(name=app_name, bundle_id=current_bundle_id)
|
|
396
|
+
|
|
397
|
+
# Reset on closing brace
|
|
398
|
+
if line == "};":
|
|
399
|
+
current_bundle_id = None
|
|
400
|
+
|
|
401
|
+
return IOSAppInfo(name=app_name, bundle_id=None)
|
|
402
|
+
except Exception as e:
|
|
403
|
+
logger.debug(f"Failed to get current app: {e}")
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
async def describe_all(self) -> list[dict[str, Any]] | None:
|
|
407
|
+
try:
|
|
408
|
+
cmd = ["idb", "ui", "describe-all", "--udid", self.udid, "--json"]
|
|
409
|
+
process = await asyncio.create_subprocess_exec(
|
|
410
|
+
*cmd,
|
|
411
|
+
stdout=asyncio.subprocess.PIPE,
|
|
412
|
+
stderr=asyncio.subprocess.PIPE,
|
|
413
|
+
)
|
|
414
|
+
stdout, stderr = await process.communicate()
|
|
415
|
+
|
|
416
|
+
if process.returncode != 0:
|
|
417
|
+
logger.error(f"idb describe-all failed: {stderr.decode()}")
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
parsed = json.loads(stdout.decode())
|
|
421
|
+
return parsed if isinstance(parsed, list) else [parsed]
|
|
422
|
+
except Exception as e:
|
|
423
|
+
logger.error(f"Failed to describe_all: {e}")
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
@with_idb_client
|
|
427
|
+
async def describe_point(self, x: int, y: int) -> dict[str, Any] | None:
|
|
428
|
+
accessibility_info = await self.client.accessibility_info(point=(x, y), nested=True)
|
|
429
|
+
return json.loads(accessibility_info.json)
|