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,526 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import wda
|
|
9
|
+
from wda.exceptions import WDAError, WDARequestError
|
|
10
|
+
|
|
11
|
+
from minitap.mobile_use.clients.idb_client import IOSAppInfo
|
|
12
|
+
from minitap.mobile_use.clients.ios_client_config import WdaClientConfig
|
|
13
|
+
from minitap.mobile_use.clients.wda_lifecycle import (
|
|
14
|
+
build_and_run_wda,
|
|
15
|
+
check_iproxy_running,
|
|
16
|
+
check_wda_running,
|
|
17
|
+
get_wda_setup_instructions,
|
|
18
|
+
parse_wda_port_from_url,
|
|
19
|
+
start_iproxy,
|
|
20
|
+
wait_for_wda,
|
|
21
|
+
)
|
|
22
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def with_wda_client(func):
|
|
28
|
+
"""Decorator to handle WDA client lifecycle and error handling.
|
|
29
|
+
|
|
30
|
+
This decorator ensures that WDA operations are properly wrapped with
|
|
31
|
+
error handling and logging. Unlike IDB which requires building a new
|
|
32
|
+
client connection for each operation, WDA maintains a persistent session.
|
|
33
|
+
|
|
34
|
+
Note: Function must have None or bool in return type for error fallback.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@wraps(func)
|
|
38
|
+
async def wrapper(self, *args, **kwargs):
|
|
39
|
+
method_name = func.__name__
|
|
40
|
+
try:
|
|
41
|
+
logger.debug(f"Executing WDA operation: {method_name}...")
|
|
42
|
+
result = await func(self, *args, **kwargs)
|
|
43
|
+
logger.debug(f"{method_name} completed successfully")
|
|
44
|
+
return result
|
|
45
|
+
except WDARequestError as e:
|
|
46
|
+
logger.error(f"WDA request error in {method_name}: {e}")
|
|
47
|
+
return_type = func.__annotations__.get("return")
|
|
48
|
+
if return_type is bool:
|
|
49
|
+
return False
|
|
50
|
+
return None
|
|
51
|
+
except WDAError as e:
|
|
52
|
+
logger.error(f"WDA error in {method_name}: {e}")
|
|
53
|
+
return_type = func.__annotations__.get("return")
|
|
54
|
+
if return_type is bool:
|
|
55
|
+
return False
|
|
56
|
+
return None
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.error(f"Failed to {method_name}: {e}")
|
|
59
|
+
import traceback
|
|
60
|
+
|
|
61
|
+
logger.debug(f"Traceback: {traceback.format_exc()}")
|
|
62
|
+
|
|
63
|
+
return_type = func.__annotations__.get("return")
|
|
64
|
+
if return_type is bool:
|
|
65
|
+
return False
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
return wrapper
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WdaClientWrapper:
|
|
72
|
+
"""Wrapper around facebook-wda client for physical iOS device automation.
|
|
73
|
+
|
|
74
|
+
This wrapper provides an interface similar to IdbClientWrapper but uses
|
|
75
|
+
WebDriverAgent (WDA) for physical iOS device automation instead of fb-idb.
|
|
76
|
+
|
|
77
|
+
WDA is used for:
|
|
78
|
+
- Physical iOS devices connected via USB
|
|
79
|
+
|
|
80
|
+
Prerequisites:
|
|
81
|
+
1. WebDriverAgent must be running on the target device
|
|
82
|
+
2. Port forwarding must be set up (e.g., iproxy 8100 8100)
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
# Basic usage with auto-start iproxy
|
|
86
|
+
wrapper = WdaClientWrapper(
|
|
87
|
+
wda_url="http://localhost:8100",
|
|
88
|
+
udid="00008130-000C04D12011401C",
|
|
89
|
+
auto_start_iproxy=True
|
|
90
|
+
)
|
|
91
|
+
await wrapper.init_client()
|
|
92
|
+
await wrapper.tap(100, 200)
|
|
93
|
+
await wrapper.cleanup()
|
|
94
|
+
|
|
95
|
+
# Using context manager
|
|
96
|
+
async with WdaClientWrapper(wda_url="http://localhost:8100") as wrapper:
|
|
97
|
+
await wrapper.tap(100, 200)
|
|
98
|
+
screenshot = await wrapper.screenshot()
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
udid: str | None = None,
|
|
104
|
+
config: WdaClientConfig | None = None,
|
|
105
|
+
):
|
|
106
|
+
"""Initialize the WDA client wrapper.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
udid: Device UDID (required for auto-starting iproxy/WDA)
|
|
110
|
+
"""
|
|
111
|
+
resolved_config = config or WdaClientConfig()
|
|
112
|
+
|
|
113
|
+
self.wda_url = resolved_config.wda_url
|
|
114
|
+
self.timeout = resolved_config.timeout
|
|
115
|
+
self.udid = udid
|
|
116
|
+
self.auto_start_iproxy = resolved_config.auto_start_iproxy
|
|
117
|
+
self.auto_start_wda = resolved_config.auto_start_wda
|
|
118
|
+
self.wda_project_path = resolved_config.wda_project_path
|
|
119
|
+
self.wda_startup_timeout = resolved_config.wda_startup_timeout
|
|
120
|
+
self._port = parse_wda_port_from_url(self.wda_url)
|
|
121
|
+
self._client: wda.Client | None = None
|
|
122
|
+
self._session: wda.Session | None = None
|
|
123
|
+
self._iproxy_process: subprocess.Popen | None = None
|
|
124
|
+
self._wda_process: subprocess.Popen | None = None
|
|
125
|
+
self._owns_iproxy: bool = False
|
|
126
|
+
self._owns_wda: bool = False
|
|
127
|
+
|
|
128
|
+
async def init_client(self) -> bool:
|
|
129
|
+
"""Initialize the WDA client connection.
|
|
130
|
+
|
|
131
|
+
This method will:
|
|
132
|
+
1. Check if iproxy is running, start it if auto_start_iproxy=True
|
|
133
|
+
2. Check if WDA is responding
|
|
134
|
+
3. Create a WDA session
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if client initialized successfully, False otherwise
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
# Step 1: Check/start iproxy if we have a UDID
|
|
141
|
+
if self.udid and self.auto_start_iproxy:
|
|
142
|
+
if not check_iproxy_running(self._port):
|
|
143
|
+
logger.info(f"iproxy not running on port {self._port}, starting...")
|
|
144
|
+
self._iproxy_process = await start_iproxy(
|
|
145
|
+
local_port=self._port,
|
|
146
|
+
device_port=self._port,
|
|
147
|
+
udid=self.udid,
|
|
148
|
+
)
|
|
149
|
+
if self._iproxy_process:
|
|
150
|
+
self._owns_iproxy = True
|
|
151
|
+
logger.info("iproxy started successfully")
|
|
152
|
+
else:
|
|
153
|
+
logger.warning(
|
|
154
|
+
"Failed to start iproxy automatically. "
|
|
155
|
+
f"Please run: iproxy {self._port} {self._port} -u {self.udid}"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
logger.debug(f"iproxy already running on port {self._port}")
|
|
159
|
+
|
|
160
|
+
# Step 2: Check if WDA is responding, auto-start if needed
|
|
161
|
+
wda_ready = await check_wda_running(self._port, timeout=5.0)
|
|
162
|
+
if not wda_ready:
|
|
163
|
+
if self.auto_start_wda and self.udid:
|
|
164
|
+
# Try to auto-start WDA
|
|
165
|
+
logger.info("WDA not responding, attempting to build and run...")
|
|
166
|
+
self._wda_process = await build_and_run_wda(
|
|
167
|
+
udid=self.udid,
|
|
168
|
+
project_path=self.wda_project_path,
|
|
169
|
+
timeout=self.wda_startup_timeout,
|
|
170
|
+
)
|
|
171
|
+
if self._wda_process:
|
|
172
|
+
self._owns_wda = True
|
|
173
|
+
# Wait for WDA to become ready
|
|
174
|
+
logger.info("Waiting for WDA to become ready...")
|
|
175
|
+
wda_ready = await wait_for_wda(
|
|
176
|
+
port=self._port,
|
|
177
|
+
timeout=60.0,
|
|
178
|
+
poll_interval=2.0,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if not wda_ready:
|
|
182
|
+
# Provide helpful error message
|
|
183
|
+
error_msg = (
|
|
184
|
+
f"WebDriverAgent not responding on port {self._port}.\n\n"
|
|
185
|
+
"Please ensure WDA is running on your device.\n"
|
|
186
|
+
)
|
|
187
|
+
if self.udid:
|
|
188
|
+
error_msg += get_wda_setup_instructions(self.udid)
|
|
189
|
+
else:
|
|
190
|
+
error_msg += (
|
|
191
|
+
"Start WDA using Xcode or xcodebuild, then run:\n"
|
|
192
|
+
f" iproxy {self._port} {self._port}\n"
|
|
193
|
+
)
|
|
194
|
+
logger.error(error_msg)
|
|
195
|
+
await self.cleanup() # Clean up any started processes
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
# Step 3: Connect to WDA
|
|
199
|
+
logger.info(f"Connecting to WebDriverAgent at {self.wda_url}")
|
|
200
|
+
self._client = await asyncio.to_thread(wda.Client, self.wda_url)
|
|
201
|
+
|
|
202
|
+
# Verify connection by getting status
|
|
203
|
+
status = await asyncio.to_thread(self._client.status)
|
|
204
|
+
logger.debug(f"WDA status: {status}")
|
|
205
|
+
|
|
206
|
+
# Create a session for operations
|
|
207
|
+
self._session = await asyncio.to_thread(self._client.session)
|
|
208
|
+
|
|
209
|
+
logger.info(f"Successfully connected to WebDriverAgent at {self.wda_url}")
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.error(f"Failed to connect to WebDriverAgent: {e}")
|
|
214
|
+
if self.udid:
|
|
215
|
+
logger.error(get_wda_setup_instructions(self.udid))
|
|
216
|
+
else:
|
|
217
|
+
logger.error(
|
|
218
|
+
"\nMake sure:\n"
|
|
219
|
+
"1. WebDriverAgent is installed using this tutorial: https://appium.github.io/appium-xcuitest-driver/4.25/setup/#installation\n"
|
|
220
|
+
f"2. Port forwarding is active: iproxy {self._port} {self._port}\n"
|
|
221
|
+
f"3. URL is correct: {self.wda_url}"
|
|
222
|
+
)
|
|
223
|
+
self._client = None
|
|
224
|
+
await self.cleanup() # Clean up any started processes
|
|
225
|
+
self._session = None
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
async def cleanup(self) -> None:
|
|
229
|
+
"""Clean up WDA client resources and stop owned processes."""
|
|
230
|
+
if self._session is not None:
|
|
231
|
+
try:
|
|
232
|
+
logger.debug("Closing WDA session")
|
|
233
|
+
await asyncio.to_thread(self._session.close)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.debug(f"Error closing WDA session: {e}")
|
|
236
|
+
finally:
|
|
237
|
+
self._session = None
|
|
238
|
+
|
|
239
|
+
self._client = None
|
|
240
|
+
|
|
241
|
+
# Stop WDA process if we started it
|
|
242
|
+
if self._owns_wda and self._wda_process:
|
|
243
|
+
try:
|
|
244
|
+
pid = self._wda_process.pid
|
|
245
|
+
logger.info(f"Stopping WDA xcodebuild process (PID: {pid})")
|
|
246
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
247
|
+
self._wda_process.wait(timeout=10)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.debug(f"Error stopping WDA: {e}")
|
|
250
|
+
finally:
|
|
251
|
+
self._wda_process = None
|
|
252
|
+
self._owns_wda = False
|
|
253
|
+
|
|
254
|
+
# Stop iproxy if we started it
|
|
255
|
+
if self._owns_iproxy and self._iproxy_process:
|
|
256
|
+
try:
|
|
257
|
+
pid = self._iproxy_process.pid
|
|
258
|
+
logger.info(f"Stopping iproxy process (PID: {pid})")
|
|
259
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
260
|
+
self._iproxy_process.wait(timeout=5)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.debug(f"Error stopping iproxy: {e}")
|
|
263
|
+
finally:
|
|
264
|
+
self._iproxy_process = None
|
|
265
|
+
self._owns_iproxy = False
|
|
266
|
+
|
|
267
|
+
logger.debug("WDA client cleanup completed")
|
|
268
|
+
|
|
269
|
+
async def __aenter__(self):
|
|
270
|
+
"""Async context manager entry."""
|
|
271
|
+
if not await self.init_client():
|
|
272
|
+
raise RuntimeError(f"Failed to connect to WebDriverAgent at {self.wda_url}")
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
276
|
+
"""Async context manager exit."""
|
|
277
|
+
await self.cleanup()
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
def _ensure_session(self) -> wda.Session:
|
|
281
|
+
"""Ensure a valid WDA session exists.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The WDA session
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
RuntimeError: If no session is available
|
|
288
|
+
"""
|
|
289
|
+
if self._session is None:
|
|
290
|
+
raise RuntimeError(
|
|
291
|
+
"WDA session not initialized. Call init_client() first or use as context manager."
|
|
292
|
+
)
|
|
293
|
+
return self._session
|
|
294
|
+
|
|
295
|
+
@with_wda_client
|
|
296
|
+
async def tap(self, x: int, y: int, duration: float | None = None) -> bool:
|
|
297
|
+
"""Tap at the specified coordinates.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
x: X coordinate
|
|
301
|
+
y: Y coordinate
|
|
302
|
+
duration: Optional tap duration in seconds (for long press)
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
True if tap succeeded, False otherwise
|
|
306
|
+
"""
|
|
307
|
+
session = self._ensure_session()
|
|
308
|
+
if duration:
|
|
309
|
+
await asyncio.to_thread(session.tap_hold, x, y, duration)
|
|
310
|
+
else:
|
|
311
|
+
await asyncio.to_thread(session.tap, x, y)
|
|
312
|
+
return True
|
|
313
|
+
|
|
314
|
+
@with_wda_client
|
|
315
|
+
async def swipe(
|
|
316
|
+
self,
|
|
317
|
+
x_start: int,
|
|
318
|
+
y_start: int,
|
|
319
|
+
x_end: int,
|
|
320
|
+
y_end: int,
|
|
321
|
+
duration: float | None = None,
|
|
322
|
+
) -> bool:
|
|
323
|
+
"""Swipe from start coordinates to end coordinates.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
x_start: Starting X coordinate
|
|
327
|
+
y_start: Starting Y coordinate
|
|
328
|
+
x_end: Ending X coordinate
|
|
329
|
+
y_end: Ending Y coordinate
|
|
330
|
+
duration: Optional swipe duration in seconds
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
True if swipe succeeded, False otherwise
|
|
334
|
+
"""
|
|
335
|
+
session = self._ensure_session()
|
|
336
|
+
await asyncio.to_thread(session.swipe, x_start, y_start, x_end, y_end, duration) # type: ignore
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
@with_wda_client
|
|
340
|
+
async def screenshot(self, output_path: str | None = None) -> bytes | None:
|
|
341
|
+
"""Take a screenshot and return raw image data.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
output_path: Optional path to save the screenshot
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Raw image data (PNG bytes) or None on failure
|
|
348
|
+
"""
|
|
349
|
+
session = self._ensure_session()
|
|
350
|
+
# Use format='raw' to get PNG bytes directly
|
|
351
|
+
screenshot_data = await asyncio.to_thread(
|
|
352
|
+
session.screenshot, png_filename=output_path, format="raw"
|
|
353
|
+
)
|
|
354
|
+
if isinstance(screenshot_data, bytes):
|
|
355
|
+
return screenshot_data
|
|
356
|
+
logger.warning(f"Expected bytes, got: {type(screenshot_data)}")
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
@with_wda_client
|
|
360
|
+
async def launch(
|
|
361
|
+
self,
|
|
362
|
+
bundle_id: str,
|
|
363
|
+
args: list[str] | None = None,
|
|
364
|
+
env: dict[str, str] | None = None,
|
|
365
|
+
) -> bool:
|
|
366
|
+
"""Launch an application by bundle ID.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
bundle_id: The bundle identifier of the app to launch
|
|
370
|
+
args: Optional list of arguments to pass to the app
|
|
371
|
+
env: Optional environment variables for the app
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
True if launch succeeded, False otherwise
|
|
375
|
+
"""
|
|
376
|
+
session = self._ensure_session()
|
|
377
|
+
await asyncio.to_thread(
|
|
378
|
+
session.app_launch,
|
|
379
|
+
bundle_id,
|
|
380
|
+
arguments=args or [],
|
|
381
|
+
environment=env or {},
|
|
382
|
+
)
|
|
383
|
+
return True
|
|
384
|
+
|
|
385
|
+
@with_wda_client
|
|
386
|
+
async def terminate(self, bundle_id: str) -> bool:
|
|
387
|
+
"""Terminate an application by bundle ID.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
bundle_id: The bundle identifier of the app to terminate
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
True if termination succeeded, False otherwise
|
|
394
|
+
"""
|
|
395
|
+
session = self._ensure_session()
|
|
396
|
+
await asyncio.to_thread(session.app_terminate, bundle_id)
|
|
397
|
+
return True
|
|
398
|
+
|
|
399
|
+
@with_wda_client
|
|
400
|
+
async def text(self, text: str) -> bool:
|
|
401
|
+
"""Type text using the keyboard.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
text: The text to type
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
True if text input succeeded, False otherwise
|
|
408
|
+
"""
|
|
409
|
+
session = self._ensure_session()
|
|
410
|
+
await asyncio.to_thread(session.send_keys, text)
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
@with_wda_client
|
|
414
|
+
async def open_url(self, url: str) -> bool:
|
|
415
|
+
"""Open a URL on the device.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
url: The URL to open
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
True if URL opened successfully, False otherwise
|
|
422
|
+
"""
|
|
423
|
+
session = self._ensure_session()
|
|
424
|
+
await asyncio.to_thread(session.open_url, url)
|
|
425
|
+
return True
|
|
426
|
+
|
|
427
|
+
@with_wda_client
|
|
428
|
+
async def key(self, key_code: int) -> bool:
|
|
429
|
+
"""Send a key press.
|
|
430
|
+
|
|
431
|
+
Note: WDA doesn't have direct key code support like IDB.
|
|
432
|
+
For delete (key_code=42), we send a backspace character.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
key_code: HID key code (42 = delete/backspace)
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
True if key press succeeded, False otherwise
|
|
439
|
+
"""
|
|
440
|
+
session = self._ensure_session()
|
|
441
|
+
if key_code == 42: # Delete/backspace
|
|
442
|
+
await asyncio.to_thread(session.send_keys, "\b")
|
|
443
|
+
return True
|
|
444
|
+
|
|
445
|
+
@with_wda_client
|
|
446
|
+
async def button(self, button_type: Any) -> bool:
|
|
447
|
+
"""Press a hardware button (compatible with IDB's HIDButtonType).
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
button_type: Button type (HIDButtonType.HOME, etc.)
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
True if button press succeeded, False otherwise
|
|
454
|
+
"""
|
|
455
|
+
client = self._client
|
|
456
|
+
if client is None:
|
|
457
|
+
raise RuntimeError("WDA client not initialized")
|
|
458
|
+
button_name = getattr(button_type, "name", str(button_type)).lower()
|
|
459
|
+
if button_name == "home":
|
|
460
|
+
await asyncio.to_thread(client.home)
|
|
461
|
+
elif button_name in ("volume_up", "volumeup"):
|
|
462
|
+
session = self._ensure_session()
|
|
463
|
+
await asyncio.to_thread(session.press, "volumeUp")
|
|
464
|
+
elif button_name in ("volume_down", "volumedown"):
|
|
465
|
+
session = self._ensure_session()
|
|
466
|
+
await asyncio.to_thread(session.press, "volumeDown")
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
async def describe_all(self) -> list[dict[str, Any]] | None:
|
|
470
|
+
"""Get UI hierarchy as a flat list (compatible with IDB's describe_all).
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
List of UI elements or None on error
|
|
474
|
+
"""
|
|
475
|
+
try:
|
|
476
|
+
session = self._ensure_session()
|
|
477
|
+
xml_source = await asyncio.to_thread(session.source, format="xml")
|
|
478
|
+
if xml_source is None:
|
|
479
|
+
return None
|
|
480
|
+
return self._parse_xml_to_elements(xml_source)
|
|
481
|
+
except Exception as e:
|
|
482
|
+
logger.error(f"Failed to describe_all: {e}")
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
def _parse_xml_to_elements(self, xml_source: str) -> list[dict[str, Any]]:
|
|
486
|
+
"""Parse WDA XML source into flat element list matching IDB format."""
|
|
487
|
+
import xml.etree.ElementTree as ET
|
|
488
|
+
|
|
489
|
+
elements = []
|
|
490
|
+
try:
|
|
491
|
+
root = ET.fromstring(xml_source)
|
|
492
|
+
for elem in root.iter():
|
|
493
|
+
if elem.tag == "AppiumAUT":
|
|
494
|
+
continue
|
|
495
|
+
frame = {
|
|
496
|
+
"x": float(elem.get("x", 0)),
|
|
497
|
+
"y": float(elem.get("y", 0)),
|
|
498
|
+
"width": float(elem.get("width", 0)),
|
|
499
|
+
"height": float(elem.get("height", 0)),
|
|
500
|
+
}
|
|
501
|
+
element = {
|
|
502
|
+
"type": elem.get("type", elem.tag),
|
|
503
|
+
"value": elem.get("value", ""),
|
|
504
|
+
"label": elem.get("label", elem.get("name", "")),
|
|
505
|
+
"frame": frame,
|
|
506
|
+
"enabled": elem.get("enabled", "false").lower() == "true",
|
|
507
|
+
"visible": elem.get("visible", "true").lower() == "true",
|
|
508
|
+
}
|
|
509
|
+
elements.append(element)
|
|
510
|
+
except ET.ParseError as e:
|
|
511
|
+
logger.error(f"Failed to parse XML: {e}")
|
|
512
|
+
return elements
|
|
513
|
+
|
|
514
|
+
@with_wda_client
|
|
515
|
+
async def app_current(self) -> IOSAppInfo | None:
|
|
516
|
+
"""Get information about the currently active app.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Dictionary with pid, name, bundleId or None on error
|
|
520
|
+
"""
|
|
521
|
+
session = self._ensure_session()
|
|
522
|
+
result = await asyncio.to_thread(session.app_current)
|
|
523
|
+
return IOSAppInfo(
|
|
524
|
+
name=result.get("name"),
|
|
525
|
+
bundle_id=result.get("bundleId"),
|
|
526
|
+
)
|