mobile-mcp-ai 2.2.6__py3-none-any.whl → 2.5.3__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.
- mobile_mcp/config.py +3 -2
- mobile_mcp/core/basic_tools_lite.py +3193 -0
- mobile_mcp/core/ios_client_wda.py +569 -0
- mobile_mcp/core/ios_device_manager_wda.py +306 -0
- mobile_mcp/core/mobile_client.py +246 -20
- mobile_mcp/core/template_matcher.py +429 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_151217.png +0 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_152037.png +0 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_152840.png +0 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_153256.png +0 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_154847.png +0 -0
- mobile_mcp/core/templates/close_buttons/gray_x_stock_ad.png +0 -0
- mobile_mcp/mcp_tools/__init__.py +10 -0
- mobile_mcp/mcp_tools/mcp_server.py +992 -0
- mobile_mcp_ai-2.5.3.dist-info/METADATA +456 -0
- mobile_mcp_ai-2.5.3.dist-info/RECORD +32 -0
- mobile_mcp_ai-2.5.3.dist-info/entry_points.txt +2 -0
- mobile_mcp/core/ai/__init__.py +0 -11
- mobile_mcp/core/ai/ai_analyzer.py +0 -197
- mobile_mcp/core/ai/ai_config.py +0 -116
- mobile_mcp/core/ai/ai_platform_adapter.py +0 -399
- mobile_mcp/core/ai/smart_test_executor.py +0 -520
- mobile_mcp/core/ai/test_generator.py +0 -365
- mobile_mcp/core/ai/test_generator_from_history.py +0 -391
- mobile_mcp/core/ai/test_generator_standalone.py +0 -293
- mobile_mcp/core/assertion/__init__.py +0 -9
- mobile_mcp/core/assertion/smart_assertion.py +0 -341
- mobile_mcp/core/basic_tools.py +0 -945
- mobile_mcp/core/h5/__init__.py +0 -10
- mobile_mcp/core/h5/h5_handler.py +0 -548
- mobile_mcp/core/ios_client.py +0 -219
- mobile_mcp/core/ios_device_manager.py +0 -252
- mobile_mcp/core/locator/__init__.py +0 -10
- mobile_mcp/core/locator/cursor_ai_auto_analyzer.py +0 -119
- mobile_mcp/core/locator/cursor_vision_helper.py +0 -414
- mobile_mcp/core/locator/mobile_smart_locator.py +0 -1747
- mobile_mcp/core/locator/position_analyzer.py +0 -813
- mobile_mcp/core/locator/script_updater.py +0 -157
- mobile_mcp/core/nl_test_runner.py +0 -585
- mobile_mcp/core/smart_app_launcher.py +0 -421
- mobile_mcp/core/smart_tools.py +0 -311
- mobile_mcp/mcp/__init__.py +0 -13
- mobile_mcp/mcp/mcp_server.py +0 -1126
- mobile_mcp/mcp/mcp_server_simple.py +0 -23
- mobile_mcp/vision/__init__.py +0 -10
- mobile_mcp/vision/vision_locator.py +0 -405
- mobile_mcp_ai-2.2.6.dist-info/METADATA +0 -503
- mobile_mcp_ai-2.2.6.dist-info/RECORD +0 -49
- mobile_mcp_ai-2.2.6.dist-info/entry_points.txt +0 -2
- {mobile_mcp_ai-2.2.6.dist-info → mobile_mcp_ai-2.5.3.dist-info}/WHEEL +0 -0
- {mobile_mcp_ai-2.2.6.dist-info → mobile_mcp_ai-2.5.3.dist-info}/licenses/LICENSE +0 -0
- {mobile_mcp_ai-2.2.6.dist-info → mobile_mcp_ai-2.5.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
iOS客户端 - 使用 facebook-wda(API风格和 uiautomator2 一样)
|
|
5
|
+
|
|
6
|
+
优势:
|
|
7
|
+
1. API和Android端(uiautomator2)几乎完全一致
|
|
8
|
+
2. 不需要Appium Server
|
|
9
|
+
3. 代码可以跨平台复用
|
|
10
|
+
|
|
11
|
+
用法:
|
|
12
|
+
client = IOSClientWDA(device_id=None)
|
|
13
|
+
await client.launch_app("com.example.app")
|
|
14
|
+
await client.click("登录") # 和Android端一样的调用方式!
|
|
15
|
+
"""
|
|
16
|
+
import asyncio
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from typing import Dict, Optional, List
|
|
20
|
+
|
|
21
|
+
from .ios_device_manager_wda import IOSDeviceManagerWDA
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IOSClientWDA:
|
|
25
|
+
"""
|
|
26
|
+
iOS客户端 - 使用 facebook-wda
|
|
27
|
+
|
|
28
|
+
API风格和MobileClient(Android)保持一致
|
|
29
|
+
|
|
30
|
+
用法:
|
|
31
|
+
client = IOSClientWDA(device_id=None)
|
|
32
|
+
await client.launch_app("com.apple.Preferences")
|
|
33
|
+
await client.click("通用")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, device_id: Optional[str] = None, lazy_connect: bool = False):
|
|
37
|
+
"""
|
|
38
|
+
初始化iOS客户端
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
device_id: 设备UDID,None则自动选择第一个设备
|
|
42
|
+
lazy_connect: 是否延迟连接(默认False)
|
|
43
|
+
"""
|
|
44
|
+
self.device_manager = IOSDeviceManagerWDA()
|
|
45
|
+
self._device_id = device_id
|
|
46
|
+
self._lazy_connect = lazy_connect
|
|
47
|
+
|
|
48
|
+
if not lazy_connect:
|
|
49
|
+
self.wda = self.device_manager.connect(device_id)
|
|
50
|
+
else:
|
|
51
|
+
self.wda = None
|
|
52
|
+
|
|
53
|
+
# 操作历史(用于录制)
|
|
54
|
+
self.operation_history: List[Dict] = []
|
|
55
|
+
|
|
56
|
+
# 缓存
|
|
57
|
+
self._snapshot_cache = None
|
|
58
|
+
self._cache_timestamp = 0
|
|
59
|
+
self._cache_ttl = 1 # 缓存1秒
|
|
60
|
+
|
|
61
|
+
def _ensure_connected(self):
|
|
62
|
+
"""确保设备已连接"""
|
|
63
|
+
if self.wda is None:
|
|
64
|
+
self.wda = self.device_manager.connect(self._device_id)
|
|
65
|
+
|
|
66
|
+
async def snapshot(self, use_cache: bool = True) -> str:
|
|
67
|
+
"""
|
|
68
|
+
获取页面XML结构
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
use_cache: 是否使用缓存
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
页面结构字符串
|
|
75
|
+
"""
|
|
76
|
+
self._ensure_connected()
|
|
77
|
+
|
|
78
|
+
# 检查缓存
|
|
79
|
+
if use_cache and self._snapshot_cache:
|
|
80
|
+
current_time = time.time()
|
|
81
|
+
if current_time - self._cache_timestamp < self._cache_ttl:
|
|
82
|
+
return self._snapshot_cache
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
# 获取页面源码
|
|
86
|
+
source = self.wda.source()
|
|
87
|
+
|
|
88
|
+
# 更新缓存
|
|
89
|
+
self._snapshot_cache = source
|
|
90
|
+
self._cache_timestamp = time.time()
|
|
91
|
+
|
|
92
|
+
return source
|
|
93
|
+
except Exception as e:
|
|
94
|
+
raise RuntimeError(f"获取页面结构失败: {e}")
|
|
95
|
+
|
|
96
|
+
async def click(self, element: str, ref: Optional[str] = None, verify: bool = True):
|
|
97
|
+
"""
|
|
98
|
+
点击元素(API和Android端一致)
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
element: 元素描述(自然语言)
|
|
102
|
+
ref: 元素定位器,支持多种格式:
|
|
103
|
+
- text: 如 "登录"
|
|
104
|
+
- accessibility_id: 如 "login_button"
|
|
105
|
+
- xpath: 如 "//XCUIElementTypeButton[@name='登录']"
|
|
106
|
+
- bounds: 如 "[100,200][300,400]" 或坐标 (x, y)
|
|
107
|
+
verify: 是否验证点击成功
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
操作结果
|
|
111
|
+
"""
|
|
112
|
+
self._ensure_connected()
|
|
113
|
+
|
|
114
|
+
# 记录操作
|
|
115
|
+
operation_record = {
|
|
116
|
+
'action': 'click',
|
|
117
|
+
'element': element,
|
|
118
|
+
'ref': ref,
|
|
119
|
+
'success': False,
|
|
120
|
+
}
|
|
121
|
+
self.operation_history.append(operation_record)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
if ref:
|
|
125
|
+
# 根据ref类型执行点击
|
|
126
|
+
if ref.startswith('//'):
|
|
127
|
+
# XPath
|
|
128
|
+
elem = self.wda(xpath=ref)
|
|
129
|
+
elif ref.startswith('[') and '][' in ref:
|
|
130
|
+
# bounds坐标 "[x1,y1][x2,y2]"
|
|
131
|
+
x, y = self._parse_bounds_coords(ref)
|
|
132
|
+
self.wda.click(x, y)
|
|
133
|
+
print(f" ✅ 坐标点击成功: ({x}, {y})", file=sys.stderr)
|
|
134
|
+
operation_record['success'] = True
|
|
135
|
+
return {"success": True, "ref": ref}
|
|
136
|
+
elif ',' in ref and ref.replace(',', '').replace(' ', '').isdigit():
|
|
137
|
+
# 直接坐标 "x,y"
|
|
138
|
+
parts = ref.split(',')
|
|
139
|
+
x, y = int(parts[0].strip()), int(parts[1].strip())
|
|
140
|
+
self.wda.click(x, y)
|
|
141
|
+
print(f" ✅ 坐标点击成功: ({x}, {y})", file=sys.stderr)
|
|
142
|
+
operation_record['success'] = True
|
|
143
|
+
return {"success": True, "ref": ref}
|
|
144
|
+
else:
|
|
145
|
+
# 默认尝试多种定位方式
|
|
146
|
+
elem = self._find_element(ref)
|
|
147
|
+
else:
|
|
148
|
+
# 使用元素描述进行定位
|
|
149
|
+
elem = self._find_element(element)
|
|
150
|
+
|
|
151
|
+
# 点击元素
|
|
152
|
+
if elem and elem.exists:
|
|
153
|
+
elem.click()
|
|
154
|
+
print(f" ✅ 点击成功: {ref or element}", file=sys.stderr)
|
|
155
|
+
operation_record['success'] = True
|
|
156
|
+
|
|
157
|
+
# 等待页面稳定
|
|
158
|
+
await asyncio.sleep(0.3)
|
|
159
|
+
|
|
160
|
+
return {"success": True, "ref": ref or element}
|
|
161
|
+
else:
|
|
162
|
+
raise ValueError(f"未找到元素: {ref or element}")
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
operation_record['error'] = str(e)
|
|
166
|
+
print(f" ❌ 点击失败: {e}", file=sys.stderr)
|
|
167
|
+
return {"success": False, "reason": str(e)}
|
|
168
|
+
|
|
169
|
+
def _find_element(self, locator: str):
|
|
170
|
+
"""
|
|
171
|
+
尝试多种方式定位元素
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
locator: 定位器字符串
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
元素对象或None
|
|
178
|
+
"""
|
|
179
|
+
# 尝试顺序:name > label > text > accessibility_id
|
|
180
|
+
strategies = [
|
|
181
|
+
lambda: self.wda(name=locator),
|
|
182
|
+
lambda: self.wda(label=locator),
|
|
183
|
+
lambda: self.wda(text=locator),
|
|
184
|
+
lambda: self.wda(nameContains=locator),
|
|
185
|
+
lambda: self.wda(labelContains=locator),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
for strategy in strategies:
|
|
189
|
+
try:
|
|
190
|
+
elem = strategy()
|
|
191
|
+
if elem.exists:
|
|
192
|
+
return elem
|
|
193
|
+
except:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
async def type_text(self, element: str, text: str, ref: Optional[str] = None, verify: bool = True):
|
|
199
|
+
"""
|
|
200
|
+
输入文本(API和Android端一致)
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
element: 元素描述
|
|
204
|
+
text: 要输入的文本
|
|
205
|
+
ref: 元素定位器
|
|
206
|
+
verify: 是否验证输入成功
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
操作结果
|
|
210
|
+
"""
|
|
211
|
+
self._ensure_connected()
|
|
212
|
+
|
|
213
|
+
# 记录操作
|
|
214
|
+
operation_record = {
|
|
215
|
+
'action': 'type',
|
|
216
|
+
'element': element,
|
|
217
|
+
'text': text,
|
|
218
|
+
'ref': ref,
|
|
219
|
+
'success': False,
|
|
220
|
+
}
|
|
221
|
+
self.operation_history.append(operation_record)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
if ref:
|
|
225
|
+
if ref.startswith('//'):
|
|
226
|
+
elem = self.wda(xpath=ref)
|
|
227
|
+
else:
|
|
228
|
+
elem = self._find_element(ref)
|
|
229
|
+
else:
|
|
230
|
+
# 查找第一个输入框
|
|
231
|
+
elem = self.wda(className='XCUIElementTypeTextField')
|
|
232
|
+
if not elem.exists:
|
|
233
|
+
elem = self.wda(className='XCUIElementTypeSecureTextField')
|
|
234
|
+
if not elem.exists:
|
|
235
|
+
elem = self._find_element(element)
|
|
236
|
+
|
|
237
|
+
if elem and elem.exists:
|
|
238
|
+
elem.clear_text()
|
|
239
|
+
elem.set_text(text)
|
|
240
|
+
print(f" ✅ 输入成功: {text}", file=sys.stderr)
|
|
241
|
+
operation_record['success'] = True
|
|
242
|
+
return {"success": True, "ref": ref or element}
|
|
243
|
+
else:
|
|
244
|
+
raise ValueError(f"未找到输入框: {ref or element}")
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
operation_record['error'] = str(e)
|
|
248
|
+
print(f" ❌ 输入失败: {e}", file=sys.stderr)
|
|
249
|
+
return {"success": False, "reason": str(e)}
|
|
250
|
+
|
|
251
|
+
async def swipe(self, direction: str, distance: int = 500, verify: bool = True):
|
|
252
|
+
"""
|
|
253
|
+
滑动操作
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
direction: 滑动方向 ('up', 'down', 'left', 'right')
|
|
257
|
+
distance: 滑动距离(像素)
|
|
258
|
+
verify: 是否验证滑动成功
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
操作结果
|
|
262
|
+
"""
|
|
263
|
+
self._ensure_connected()
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
# 获取屏幕尺寸
|
|
267
|
+
window = self.wda.window_size()
|
|
268
|
+
width = window.width
|
|
269
|
+
height = window.height
|
|
270
|
+
|
|
271
|
+
# 计算滑动坐标
|
|
272
|
+
center_x = width // 2
|
|
273
|
+
center_y = height // 2
|
|
274
|
+
|
|
275
|
+
direction_map = {
|
|
276
|
+
'up': (center_x, int(height * 0.8), center_x, int(height * 0.2)),
|
|
277
|
+
'down': (center_x, int(height * 0.2), center_x, int(height * 0.8)),
|
|
278
|
+
'left': (int(width * 0.8), center_y, int(width * 0.2), center_y),
|
|
279
|
+
'right': (int(width * 0.2), center_y, int(width * 0.8), center_y),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if direction not in direction_map:
|
|
283
|
+
return {"success": False, "reason": f"不支持的滑动方向: {direction}"}
|
|
284
|
+
|
|
285
|
+
x1, y1, x2, y2 = direction_map[direction]
|
|
286
|
+
|
|
287
|
+
print(f" 📍 滑动方向: {direction}, 坐标: ({x1}, {y1}) -> ({x2}, {y2})", file=sys.stderr)
|
|
288
|
+
self.wda.swipe(x1, y1, x2, y2, duration=0.5)
|
|
289
|
+
|
|
290
|
+
print(f" ✅ 滑动成功: {direction}", file=sys.stderr)
|
|
291
|
+
return {"success": True, "direction": direction}
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
print(f" ❌ 滑动失败: {e}", file=sys.stderr)
|
|
295
|
+
return {"success": False, "reason": str(e)}
|
|
296
|
+
|
|
297
|
+
async def launch_app(self, bundle_id: str, wait_time: int = 3):
|
|
298
|
+
"""
|
|
299
|
+
启动应用
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
bundle_id: 应用Bundle ID,如 'com.apple.Preferences'
|
|
303
|
+
wait_time: 等待时间(秒)
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
操作结果
|
|
307
|
+
"""
|
|
308
|
+
self._ensure_connected()
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
print(f" 📱 启动App: {bundle_id}", file=sys.stderr)
|
|
312
|
+
|
|
313
|
+
# 使用 wda 启动应用
|
|
314
|
+
self.wda.session().app_activate(bundle_id)
|
|
315
|
+
|
|
316
|
+
# 等待应用启动
|
|
317
|
+
await asyncio.sleep(wait_time)
|
|
318
|
+
|
|
319
|
+
# 验证是否启动成功
|
|
320
|
+
current = await self.get_current_package()
|
|
321
|
+
if current == bundle_id:
|
|
322
|
+
print(f" ✅ App启动成功: {bundle_id}", file=sys.stderr)
|
|
323
|
+
return {"success": True, "package": bundle_id}
|
|
324
|
+
else:
|
|
325
|
+
print(f" ⚠️ App可能未启动成功,当前App: {current}", file=sys.stderr)
|
|
326
|
+
return {"success": True, "package": bundle_id, "warning": f"当前App: {current}"}
|
|
327
|
+
|
|
328
|
+
except Exception as e:
|
|
329
|
+
print(f" ❌ App启动失败: {e}", file=sys.stderr)
|
|
330
|
+
return {"success": False, "reason": str(e)}
|
|
331
|
+
|
|
332
|
+
async def stop_app(self, bundle_id: str):
|
|
333
|
+
"""
|
|
334
|
+
停止应用
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
bundle_id: 应用Bundle ID
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
操作结果
|
|
341
|
+
"""
|
|
342
|
+
self._ensure_connected()
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
print(f" 📱 停止App: {bundle_id}", file=sys.stderr)
|
|
346
|
+
self.wda.session().app_terminate(bundle_id)
|
|
347
|
+
print(f" ✅ App已停止: {bundle_id}", file=sys.stderr)
|
|
348
|
+
return {"success": True}
|
|
349
|
+
except Exception as e:
|
|
350
|
+
print(f" ❌ App停止失败: {e}", file=sys.stderr)
|
|
351
|
+
return {"success": False, "reason": str(e)}
|
|
352
|
+
|
|
353
|
+
async def get_current_package(self) -> Optional[str]:
|
|
354
|
+
"""获取当前前台应用的Bundle ID"""
|
|
355
|
+
self._ensure_connected()
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
app_info = self.wda.session().app_current()
|
|
359
|
+
return app_info.get('bundleId')
|
|
360
|
+
except:
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
async def press_key(self, key: str, verify: bool = True):
|
|
364
|
+
"""
|
|
365
|
+
按键盘按键
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
key: 按键名称,支持:
|
|
369
|
+
- "enter" / "回车" - Enter键
|
|
370
|
+
- "back" / "返回" - 返回(在iOS上是导航返回)
|
|
371
|
+
- "home" - Home键
|
|
372
|
+
verify: 是否验证按键效果
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
操作结果
|
|
376
|
+
"""
|
|
377
|
+
self._ensure_connected()
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
key_lower = key.lower()
|
|
381
|
+
|
|
382
|
+
if key_lower in ['enter', '回车', 'return']:
|
|
383
|
+
# 发送回车键
|
|
384
|
+
self.wda(className='XCUIElementTypeKeyboard').buttons['return'].click()
|
|
385
|
+
print(f" ✅ 按键成功: Enter", file=sys.stderr)
|
|
386
|
+
elif key_lower in ['back', '返回']:
|
|
387
|
+
# iOS没有真正的返回键,尝试点击导航栏的返回按钮
|
|
388
|
+
back_buttons = [
|
|
389
|
+
self.wda(name='返回'),
|
|
390
|
+
self.wda(name='Back'),
|
|
391
|
+
self.wda(label='返回'),
|
|
392
|
+
self.wda(label='Back'),
|
|
393
|
+
]
|
|
394
|
+
clicked = False
|
|
395
|
+
for btn in back_buttons:
|
|
396
|
+
if btn.exists:
|
|
397
|
+
btn.click()
|
|
398
|
+
clicked = True
|
|
399
|
+
break
|
|
400
|
+
|
|
401
|
+
if clicked:
|
|
402
|
+
print(f" ✅ 返回按钮点击成功", file=sys.stderr)
|
|
403
|
+
else:
|
|
404
|
+
# 如果没有返回按钮,尝试从左边缘滑动
|
|
405
|
+
window = self.wda.window_size()
|
|
406
|
+
self.wda.swipe(0, window.height // 2, window.width // 2, window.height // 2)
|
|
407
|
+
print(f" ✅ 边缘滑动返回成功", file=sys.stderr)
|
|
408
|
+
elif key_lower == 'home':
|
|
409
|
+
# 按Home键
|
|
410
|
+
self.wda.home()
|
|
411
|
+
print(f" ✅ 按键成功: Home", file=sys.stderr)
|
|
412
|
+
else:
|
|
413
|
+
return {"success": False, "reason": f"不支持的按键: {key}"}
|
|
414
|
+
|
|
415
|
+
return {"success": True, "key": key, "verified": False}
|
|
416
|
+
|
|
417
|
+
except Exception as e:
|
|
418
|
+
print(f" ❌ 按键失败: {e}", file=sys.stderr)
|
|
419
|
+
return {"success": False, "reason": str(e)}
|
|
420
|
+
|
|
421
|
+
async def take_screenshot(self, filename: Optional[str] = None) -> str:
|
|
422
|
+
"""
|
|
423
|
+
截图
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
filename: 保存的文件名(可选)
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
截图文件路径
|
|
430
|
+
"""
|
|
431
|
+
self._ensure_connected()
|
|
432
|
+
|
|
433
|
+
import os
|
|
434
|
+
from datetime import datetime
|
|
435
|
+
|
|
436
|
+
if not filename:
|
|
437
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
438
|
+
filename = f"ios_screenshot_{timestamp}.png"
|
|
439
|
+
|
|
440
|
+
# 确保截图目录存在
|
|
441
|
+
screenshots_dir = os.path.join(os.getcwd(), 'screenshots')
|
|
442
|
+
os.makedirs(screenshots_dir, exist_ok=True)
|
|
443
|
+
|
|
444
|
+
filepath = os.path.join(screenshots_dir, filename)
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
# 使用 wda 截图
|
|
448
|
+
self.wda.screenshot(filepath)
|
|
449
|
+
print(f" 📸 截图已保存: {filepath}", file=sys.stderr)
|
|
450
|
+
return filepath
|
|
451
|
+
except Exception as e:
|
|
452
|
+
print(f" ❌ 截图失败: {e}", file=sys.stderr)
|
|
453
|
+
raise
|
|
454
|
+
|
|
455
|
+
def _parse_bounds_coords(self, bounds_str: str) -> tuple:
|
|
456
|
+
"""
|
|
457
|
+
解析bounds字符串,返回中心点坐标
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
bounds_str: 格式如 "[100,200][300,400]"
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
(x, y) 中心点坐标
|
|
464
|
+
"""
|
|
465
|
+
import re
|
|
466
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
467
|
+
if match:
|
|
468
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
469
|
+
return ((x1 + x2) // 2, (y1 + y2) // 2)
|
|
470
|
+
return (0, 0)
|
|
471
|
+
|
|
472
|
+
def get_screen_size(self) -> tuple:
|
|
473
|
+
"""获取屏幕尺寸"""
|
|
474
|
+
self._ensure_connected()
|
|
475
|
+
|
|
476
|
+
window = self.wda.window_size()
|
|
477
|
+
return (window.width, window.height)
|
|
478
|
+
|
|
479
|
+
def list_elements(self) -> List[Dict]:
|
|
480
|
+
"""
|
|
481
|
+
列出所有可交互元素(类似Android的mobile_list_elements)
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
元素列表
|
|
485
|
+
"""
|
|
486
|
+
self._ensure_connected()
|
|
487
|
+
|
|
488
|
+
elements = []
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
# 获取页面源码并解析
|
|
492
|
+
source = self.wda.source(format='json')
|
|
493
|
+
|
|
494
|
+
def extract_elements(node, depth=0):
|
|
495
|
+
"""递归提取元素"""
|
|
496
|
+
if not isinstance(node, dict):
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
elem_type = node.get('type', '')
|
|
500
|
+
name = node.get('name', '')
|
|
501
|
+
label = node.get('label', '')
|
|
502
|
+
value = node.get('value', '')
|
|
503
|
+
rect = node.get('rect', {})
|
|
504
|
+
enabled = node.get('enabled', True)
|
|
505
|
+
|
|
506
|
+
# 只收集可交互的元素
|
|
507
|
+
interactable_types = [
|
|
508
|
+
'XCUIElementTypeButton',
|
|
509
|
+
'XCUIElementTypeTextField',
|
|
510
|
+
'XCUIElementTypeSecureTextField',
|
|
511
|
+
'XCUIElementTypeTextView',
|
|
512
|
+
'XCUIElementTypeSwitch',
|
|
513
|
+
'XCUIElementTypeSlider',
|
|
514
|
+
'XCUIElementTypeLink',
|
|
515
|
+
'XCUIElementTypeCell',
|
|
516
|
+
'XCUIElementTypeStaticText',
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
if elem_type in interactable_types and enabled:
|
|
520
|
+
elements.append({
|
|
521
|
+
'type': elem_type,
|
|
522
|
+
'name': name,
|
|
523
|
+
'label': label,
|
|
524
|
+
'value': value,
|
|
525
|
+
'bounds': f"[{rect.get('x', 0)},{rect.get('y', 0)}][{rect.get('x', 0) + rect.get('width', 0)},{rect.get('y', 0) + rect.get('height', 0)}]",
|
|
526
|
+
'enabled': enabled,
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
# 递归处理子元素
|
|
530
|
+
for child in node.get('children', []):
|
|
531
|
+
extract_elements(child, depth + 1)
|
|
532
|
+
|
|
533
|
+
extract_elements(source)
|
|
534
|
+
|
|
535
|
+
except Exception as e:
|
|
536
|
+
print(f" ⚠️ 获取元素列表失败: {e}", file=sys.stderr)
|
|
537
|
+
|
|
538
|
+
return elements
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
|