screenforge 0.4.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.
- cli/__init__.py +0 -0
- cli/_version.py +1 -0
- cli/dispatch.py +266 -0
- cli/doctor.py +487 -0
- cli/modes/__init__.py +0 -0
- cli/modes/action.py +262 -0
- cli/modes/default.py +248 -0
- cli/modes/demo.py +162 -0
- cli/modes/dry_run.py +237 -0
- cli/modes/init.py +133 -0
- cli/modes/plan.py +148 -0
- cli/modes/workflow.py +354 -0
- cli/parser.py +305 -0
- cli/reporter.py +207 -0
- cli/session.py +146 -0
- cli/shared.py +427 -0
- cli/shorthand.py +90 -0
- cli/tool_protocol_handlers.py +446 -0
- common/__init__.py +0 -0
- common/adapters/__init__.py +21 -0
- common/adapters/android_adapter.py +273 -0
- common/adapters/base_adapter.py +24 -0
- common/adapters/ios_adapter.py +278 -0
- common/adapters/web_adapter.py +271 -0
- common/ai.py +277 -0
- common/ai_autonomous.py +273 -0
- common/ai_heal.py +222 -0
- common/cache/__init__.py +15 -0
- common/cache/cache_hash.py +57 -0
- common/cache/cache_manager.py +300 -0
- common/cache/cache_stats.py +133 -0
- common/cache/cache_storage.py +79 -0
- common/cache/embedding_loader.py +150 -0
- common/capabilities.py +121 -0
- common/case_memory.py +327 -0
- common/error_codes.py +61 -0
- common/exceptions.py +18 -0
- common/executor.py +1504 -0
- common/failure_diagnosis.py +138 -0
- common/history_manager.py +75 -0
- common/logs.py +168 -0
- common/mcp_server.py +467 -0
- common/preflight.py +496 -0
- common/progress.py +37 -0
- common/run_reporter.py +415 -0
- common/run_resume.py +149 -0
- common/runtime_modes.py +35 -0
- common/tool_protocol.py +196 -0
- common/visual_fallback.py +71 -0
- common/workflow_schema.py +150 -0
- config/__init__.py +0 -0
- config/config.py +167 -0
- config/env_loader.py +76 -0
- screenforge-0.4.0.dist-info/METADATA +43 -0
- screenforge-0.4.0.dist-info/RECORD +64 -0
- screenforge-0.4.0.dist-info/WHEEL +5 -0
- screenforge-0.4.0.dist-info/entry_points.txt +2 -0
- screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
- screenforge-0.4.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +0 -0
- utils/screenshot_annotator.py +60 -0
- utils/utils_ios.py +195 -0
- utils/utils_web.py +304 -0
- utils/utils_xml.py +218 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: screenforge
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: AI-driven cross-platform UI automation engine with test script generation
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
|
|
7
|
+
Project-URL: Repository, https://github.com/jhinzzz/ScreenForge
|
|
8
|
+
Project-URL: Issues, https://github.com/jhinzzz/ScreenForge/issues
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: playwright>=1.50
|
|
12
|
+
Requires-Dist: openai>=2.0
|
|
13
|
+
Requires-Dist: allure-pytest>=2.15
|
|
14
|
+
Requires-Dist: loguru>=0.7
|
|
15
|
+
Requires-Dist: pydantic>=2.0
|
|
16
|
+
Requires-Dist: python-dotenv>=1.0
|
|
17
|
+
Requires-Dist: PyYAML>=6.0
|
|
18
|
+
Requires-Dist: pillow>=12.0
|
|
19
|
+
Requires-Dist: lxml>=5.0
|
|
20
|
+
Requires-Dist: requests>=2.30
|
|
21
|
+
Requires-Dist: rich>=14.0
|
|
22
|
+
Requires-Dist: typer>=0.24
|
|
23
|
+
Requires-Dist: retry2>=0.9
|
|
24
|
+
Requires-Dist: opencv-python>=4.10
|
|
25
|
+
Requires-Dist: numpy>=1.24
|
|
26
|
+
Requires-Dist: filelock>=3.12
|
|
27
|
+
Provides-Extra: android
|
|
28
|
+
Requires-Dist: uiautomator2>=3.5; extra == "android"
|
|
29
|
+
Requires-Dist: adbutils>=2.12; extra == "android"
|
|
30
|
+
Provides-Extra: ios
|
|
31
|
+
Requires-Dist: facebook-wda>=1.0; extra == "ios"
|
|
32
|
+
Provides-Extra: ml
|
|
33
|
+
Requires-Dist: torch>=2.0; extra == "ml"
|
|
34
|
+
Requires-Dist: transformers>=5.0; extra == "ml"
|
|
35
|
+
Requires-Dist: sentence-transformers>=5.0; extra == "ml"
|
|
36
|
+
Requires-Dist: scikit-learn>=1.8; extra == "ml"
|
|
37
|
+
Provides-Extra: playground
|
|
38
|
+
Requires-Dist: fastapi>=0.115; extra == "playground"
|
|
39
|
+
Requires-Dist: uvicorn>=0.34; extra == "playground"
|
|
40
|
+
Requires-Dist: websockets>=14.0; extra == "playground"
|
|
41
|
+
Provides-Extra: all
|
|
42
|
+
Requires-Dist: screenforge[android,ios,ml,playground]; extra == "all"
|
|
43
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
cli/_version.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
|
|
3
|
+
cli/dispatch.py,sha256=2sOjAvfrn0mwOwUIgETKlGbX2Pw_iBVhp9eKGIQPS3Y,8458
|
|
4
|
+
cli/doctor.py,sha256=uWHxWXl1wive_8XDXpS8j9iK1AxYkQMpZZ9A1uJfX2U,18383
|
|
5
|
+
cli/parser.py,sha256=OtIeDfYMPBy28P5D_A7dz47Whak7UEFlbkWbghtQV5A,10170
|
|
6
|
+
cli/reporter.py,sha256=74TdEl-RcRR6T8RSGAKEvJf7rE1gRrGWXHn_Z_8c9A0,8399
|
|
7
|
+
cli/session.py,sha256=MVu7J_HVhd8MzMRHRtSI3TrlGjcA9t9vTMAMZM6RjI4,4271
|
|
8
|
+
cli/shared.py,sha256=h4I_QkGYqABSAEVo6lPWAjzLKD9HYXj-tXAkecyBdik,15237
|
|
9
|
+
cli/shorthand.py,sha256=Axjc1RsfRxvJBDEEkid0WOSw-bNGNi49kACTj0EnH0Q,3099
|
|
10
|
+
cli/tool_protocol_handlers.py,sha256=7lU_ogNScQNIHjq2EwejK_XdN7ObiyfTmukkXlhQUxY,14885
|
|
11
|
+
cli/modes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
cli/modes/action.py,sha256=0XFHPu3iva6ssvm_zVmRfr6rdQ6FgN3gttypboWM1DM,10150
|
|
13
|
+
cli/modes/default.py,sha256=4zmcn3bShqeiNGTqjJsNV38horYfla0ZvKX0hs_d0iw,11211
|
|
14
|
+
cli/modes/demo.py,sha256=Xc79dl9IED60xJcwqj1kC2CBqGSVqrn6j3qrJFFf-XA,5228
|
|
15
|
+
cli/modes/dry_run.py,sha256=ta-0CN1IDWpe9V_QQKqYLtL7VeAFATSlPZ_vfYlkfcU,8706
|
|
16
|
+
cli/modes/init.py,sha256=4Pfd55Ck8EluQGnQ_OHvt_Zin4iWEyXq5PSrNfUE7yM,4226
|
|
17
|
+
cli/modes/plan.py,sha256=-l15staToEpUR9V23gludWA3Ao7oShqe2p_bFSnfNVQ,5095
|
|
18
|
+
cli/modes/workflow.py,sha256=V-uE7LFFtzOfQ_kSMrvH_UVorwdngY-1U9ruyk3O_es,12649
|
|
19
|
+
common/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
common/ai.py,sha256=6vYmBPA-0_aCWSJckjIF0QI1SrlcXGlBylUlNXeR73Q,13459
|
|
21
|
+
common/ai_autonomous.py,sha256=zlOMZwWDm6Udw809WDTkr9QC1Re1ESK9iTp9ehIAUdk,12279
|
|
22
|
+
common/ai_heal.py,sha256=apsAizcJPuu2h3gBc0KxB1jEq7OVpX0kI5NN8S4ko7M,7501
|
|
23
|
+
common/capabilities.py,sha256=PXWRdCTMZpiBVxZ9wv8G_mk895Qg_UE2cIJP7WXdX9g,4066
|
|
24
|
+
common/case_memory.py,sha256=3Eo99vS3FPb4kRcUp8SXpHzlnXkXJX1xr1NkQeJt7Bg,11153
|
|
25
|
+
common/error_codes.py,sha256=UAUmEd4bfw62OfdfVZTmCv0v1uJZkBdMK-dkpQqiZwA,2157
|
|
26
|
+
common/exceptions.py,sha256=FXZBAWPkRPuYxtHOZSVKgk-H01zqxm2HHEhpKurhVKg,214
|
|
27
|
+
common/executor.py,sha256=nu7WGzk5prqfU9mFUbZTfczHbzxmgdP0QL9iQm-4bU4,70501
|
|
28
|
+
common/failure_diagnosis.py,sha256=zAor5pPtlYroJ8txdZrQt5HTmjDRyjSaiSN1Qb3dW0k,4758
|
|
29
|
+
common/history_manager.py,sha256=BfiRslOyyhltDRRIg_SurM2pbU5wWfrL3EEvNkFEtWE,3002
|
|
30
|
+
common/logs.py,sha256=bfrbekylNwJ01zeEO2t_pVTwyRKF8-9osKljIRBiUc0,4060
|
|
31
|
+
common/mcp_server.py,sha256=iHTrly4098RR3E3VGfQIigYOkeAZ0-M0x0XZRYcHFzs,16173
|
|
32
|
+
common/preflight.py,sha256=DVeYNjvjVRQtwAfmcl_ebKptz9RG8KeZLPu4VV94hfE,17449
|
|
33
|
+
common/progress.py,sha256=Od9_5VQN1DNza4zbr-B6zp6fWr7-ywKJFxXTDQ94CfE,926
|
|
34
|
+
common/run_reporter.py,sha256=ZK2NRWGNMTumJozFMgSTEpI6EHp3lrtpHxlqEDhP2wc,15870
|
|
35
|
+
common/run_resume.py,sha256=QGn_YRVjTOZD1tdZzlkymdx0g-MLXjGj-E8m4OM8eVI,5400
|
|
36
|
+
common/runtime_modes.py,sha256=n6o9e-A3YUxoa3_f-pzfBhACgVzv_1Ranm7q3VjgmEE,794
|
|
37
|
+
common/tool_protocol.py,sha256=_GVd1GVyLAt8NpfaBA2xND7_ACkl29bb1yMbvYf6J8M,6782
|
|
38
|
+
common/visual_fallback.py,sha256=dBvNOianmrOilWTGrrgmDpd7G3JJF5X7Nm7qi5-psCQ,2267
|
|
39
|
+
common/workflow_schema.py,sha256=-1zCBB0Tea9Rjg1-AM6TKJkwARWNJvDXTojjNs_eSUs,4698
|
|
40
|
+
common/adapters/__init__.py,sha256=EFdBr_meqoMB9Nea_mNuFlqGDHT7hi35b9A3dLFHQMo,624
|
|
41
|
+
common/adapters/android_adapter.py,sha256=wkUFXtfopdDZVAeoZGJ4j7nPfKUaDxUICJB4_BEjeE0,9699
|
|
42
|
+
common/adapters/base_adapter.py,sha256=QYsz9fqDyBtVhcqx_gTrVoIJCiV_h7ZlIhgr86o0oFk,450
|
|
43
|
+
common/adapters/ios_adapter.py,sha256=ONs9-jZN2Z3tsF0ft_A572RuB0Zmn5Gqw65N61sarZc,9671
|
|
44
|
+
common/adapters/web_adapter.py,sha256=SkPoZ4j4VEoD62MNe6XfwWLV86BvrYhclKKkCeWnxkY,10396
|
|
45
|
+
common/cache/__init__.py,sha256=6gWtxmR6uzvB8SCz-qirbF_dKpBGSvd2DUvB-_XXTKA,437
|
|
46
|
+
common/cache/cache_hash.py,sha256=YcdFgfYOl9KraLNBi-YyLzzYjdwwUzuw1pclb53MqMg,1907
|
|
47
|
+
common/cache/cache_manager.py,sha256=70oaCNFZRiV8eiwSeeVneruy6I63M9iYYXbTr3HJv5Q,12084
|
|
48
|
+
common/cache/cache_stats.py,sha256=vuWdmWiv1QS7qY-n1_ao_YDAK1bUoYOnYwwOndmCoF0,4510
|
|
49
|
+
common/cache/cache_storage.py,sha256=FO26Nih2DSsif9g_o_uJK1WExm34KQ-yxfpde39nufQ,2395
|
|
50
|
+
common/cache/embedding_loader.py,sha256=qjiXUNMYu9-oATHsnJZaW25-k3Un4AkKuLXaC8U9-IA,5424
|
|
51
|
+
config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
|
+
config/config.py,sha256=u2RkvTYtDQCPMIPVBiPKgLic0gSyW15pxbkMPWnMcNs,6778
|
|
53
|
+
config/env_loader.py,sha256=1IXZXuH62yeKXQ3l1-ZWc2I1Am1AShJ4vW6OTVibD_M,2137
|
|
54
|
+
screenforge-0.4.0.dist-info/licenses/LICENSE,sha256=yCiuqsAjnCqF1spnYbvar2ouEI7CdnU_A1lVRp-hv-4,1064
|
|
55
|
+
utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
56
|
+
utils/screenshot_annotator.py,sha256=Wuf1ogk_qTcmaTD8L6cEISQhBH2tAOB69wUQcuw4kSY,1884
|
|
57
|
+
utils/utils_ios.py,sha256=YuTzwyOJAZFqFVq6sRlf2f_yOmax9I2YA75ae_m_fpk,9136
|
|
58
|
+
utils/utils_web.py,sha256=m7bwZVfgGr6rOYVrMA9dN-UanV_nan5gnh06tU4RoFs,17565
|
|
59
|
+
utils/utils_xml.py,sha256=q26alq33OJZD7e--AlW6r2awY81zP091HQesoCEo2xQ,9569
|
|
60
|
+
screenforge-0.4.0.dist-info/METADATA,sha256=tS5imJNDLxOFeF3qr2ud1MaPT0kpL1PD5cWUhNcoGns,1564
|
|
61
|
+
screenforge-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
62
|
+
screenforge-0.4.0.dist-info/entry_points.txt,sha256=SNdpMdZH6iU6Y9Vo2lAph1LdDxDPcko8XX42aBgAzEY,50
|
|
63
|
+
screenforge-0.4.0.dist-info/top_level.txt,sha256=nS7U81ECciGCg0T8oLxLdD_5miVGgnxaWyfPUTgRvRY,24
|
|
64
|
+
screenforge-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jhinzzz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
utils/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""截图标注器: 在截图上绘制红色矩形 + ref 标签, 帮助 Agent 直观定位元素。"""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def annotate_screenshot(png_bytes: bytes, ui_elements: list[dict]) -> bytes:
|
|
9
|
+
"""在截图上为可点击元素绘制红色边框和 ref 标签。
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
png_bytes: 原始截图的 PNG 字节
|
|
13
|
+
ui_elements: compress_web_dom 返回的元素列表, 每个元素需含 ref/x/y/w/h
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
标注后的 PNG 字节
|
|
17
|
+
"""
|
|
18
|
+
img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
|
19
|
+
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
|
20
|
+
draw = ImageDraw.Draw(overlay)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 14)
|
|
24
|
+
except Exception:
|
|
25
|
+
font = ImageFont.load_default()
|
|
26
|
+
|
|
27
|
+
for el in ui_elements:
|
|
28
|
+
if not el.get("clickable"):
|
|
29
|
+
continue
|
|
30
|
+
ref = el.get("ref", "")
|
|
31
|
+
x = el.get("x", 0)
|
|
32
|
+
y = el.get("y", 0)
|
|
33
|
+
w = el.get("w", 0)
|
|
34
|
+
h = el.get("h", 0)
|
|
35
|
+
if w <= 0 or h <= 0:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# 红色半透明矩形边框
|
|
39
|
+
draw.rectangle(
|
|
40
|
+
[x, y, x + w, y + h],
|
|
41
|
+
outline=(255, 0, 0, 200),
|
|
42
|
+
width=2,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ref 标签背景 + 文字
|
|
46
|
+
if ref:
|
|
47
|
+
label = ref
|
|
48
|
+
bbox = font.getbbox(label)
|
|
49
|
+
tw = bbox[2] - bbox[0] + 6
|
|
50
|
+
th = bbox[3] - bbox[1] + 4
|
|
51
|
+
# 标签放在元素左上角上方, 避免遮挡内容
|
|
52
|
+
lx = x
|
|
53
|
+
ly = max(y - th - 2, 0)
|
|
54
|
+
draw.rectangle([lx, ly, lx + tw, ly + th], fill=(255, 0, 0, 220))
|
|
55
|
+
draw.text((lx + 3, ly + 1), label, fill=(255, 255, 255, 255), font=font)
|
|
56
|
+
|
|
57
|
+
result = Image.alpha_composite(img, overlay).convert("RGB")
|
|
58
|
+
buf = io.BytesIO()
|
|
59
|
+
result.save(buf, format="PNG")
|
|
60
|
+
return buf.getvalue()
|
utils/utils_ios.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""iOS WDA XML compression and dimensionality reduction."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
|
|
6
|
+
_SKIP_TYPES = frozenset(("Window", "Application"))
|
|
7
|
+
_KEYBOARD_TYPES = frozenset(("Key",))
|
|
8
|
+
_NOISE_LABEL_MAX_LEN = 1
|
|
9
|
+
|
|
10
|
+
# How strongly a node type represents "the actionable control" of a row. WDA
|
|
11
|
+
# repeats a row's label on a nested StaticText AND on the control that carries it
|
|
12
|
+
# (a Switch/Button, or the Cell itself); the StaticText is an inert shadow. When
|
|
13
|
+
# several elements in one row share an exact label, the highest-priority type wins
|
|
14
|
+
# and strictly-lower ones are suppressed — collapsing the inert StaticText shadow
|
|
15
|
+
# while keeping the actionable control. (Equal-priority ties — e.g. two same-label
|
|
16
|
+
# Buttons in one row — are both kept; that's two real controls, not a shadow.)
|
|
17
|
+
_INTERACTIVE_PRIORITY = {"Switch": 3, "Button": 2, "Cell": 1}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ios_type(node) -> str:
|
|
21
|
+
"""Element type without the verbose XCUIElementType prefix."""
|
|
22
|
+
return node.attrib.get("type", "").replace("XCUIElementType", "")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _interactive_priority(node_type: str) -> int:
|
|
26
|
+
"""Higher = more 'the actionable control'. A label-shadow StaticText is 0 and
|
|
27
|
+
loses to the Switch/Button/Cell carrying the same label."""
|
|
28
|
+
return _INTERACTIVE_PRIORITY.get(node_type, 0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _row_members(cell):
|
|
32
|
+
"""The Cell plus its descendants that belong to THIS row — stopping the descent
|
|
33
|
+
at any nested Cell (a separate row owns its own labels). Without this boundary,
|
|
34
|
+
an outer Cell's label group would swallow an inner row's distinct label and
|
|
35
|
+
suppress it, erasing the inner row from the output."""
|
|
36
|
+
yield cell
|
|
37
|
+
for child in cell:
|
|
38
|
+
if _ios_type(child) == "Cell":
|
|
39
|
+
continue # nested row — its labels belong to it, not to `cell`
|
|
40
|
+
yield from _row_members(child)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _compute_label_shadows(root) -> set:
|
|
44
|
+
"""Find redundant label-shadow nodes to suppress (returns a set of id(node)).
|
|
45
|
+
|
|
46
|
+
WDA models every Settings/list row as a `Cell`, and repeats the row's label on
|
|
47
|
+
a nested `StaticText` AND on the row's interactive control. A flat walk emits
|
|
48
|
+
the same label 2-3x — a Button/Cell/Switch plus a StaticText twin (a Switch row
|
|
49
|
+
yields Cell + StaticText + Switch, all labelled the same). That bloats the tree
|
|
50
|
+
and makes `d(label=...)` ambiguous (it matches the tap target AND its inert text
|
|
51
|
+
twin). Within each row we group labelled members (the Cell + its non-nested-Cell
|
|
52
|
+
descendants) by EXACT label and suppress every element whose interactive priority
|
|
53
|
+
is strictly below the group's max — keeping the actionable control per label and
|
|
54
|
+
dropping the inert StaticText shadow. Equal-priority ties are preserved (two real
|
|
55
|
+
same-label controls are not a shadow).
|
|
56
|
+
|
|
57
|
+
Identity keys are safe because `root` is held alive across both passes inside
|
|
58
|
+
compress_ios_xml; do not stream/re-parse between the passes.
|
|
59
|
+
|
|
60
|
+
Honesty boundaries (verified on a live simulator):
|
|
61
|
+
- EXACT-label match only. A real subtitle whose text merely contains the
|
|
62
|
+
control's label (e.g. the Apple-account row, whose Button carries a combined
|
|
63
|
+
label and whose StaticTexts are distinct title/subtitle) is NOT a shadow and
|
|
64
|
+
survives.
|
|
65
|
+
- Scope is the row (a Cell, NOT descending into a nested Cell — see
|
|
66
|
+
_row_members). A StaticText with no Cell ancestor (a standalone caption like
|
|
67
|
+
'关'/Off) is never grouped, so it survives; and an inner row's distinct label
|
|
68
|
+
is never eaten by the outer row that contains it.
|
|
69
|
+
- A label-group with no interactive element (all StaticText) is left untouched —
|
|
70
|
+
never suppress a row down to nothing; the row's tap target is preserved.
|
|
71
|
+
- VISIBILITY-AWARE. Pass 2 emits only visible nodes, so the priority winner is
|
|
72
|
+
chosen among VISIBLE nodes and only VISIBLE nodes are suppressed. Otherwise an
|
|
73
|
+
invisible higher-priority twin (WDA marks subviews invisible routinely) would
|
|
74
|
+
suppress the visible sibling and then be dropped itself — erasing a real,
|
|
75
|
+
on-screen row entirely (label + tap target + any value). Suppressing only
|
|
76
|
+
visible nodes is sufficient: invisible ones never reach the output anyway.
|
|
77
|
+
"""
|
|
78
|
+
suppress: set = set()
|
|
79
|
+
for cell in root.iter():
|
|
80
|
+
if _ios_type(cell) != "Cell":
|
|
81
|
+
continue
|
|
82
|
+
groups: dict = {}
|
|
83
|
+
for node in _row_members(cell): # this row only — not nested-Cell rows
|
|
84
|
+
label = node.attrib.get("label", "").strip()
|
|
85
|
+
if label:
|
|
86
|
+
groups.setdefault(label, []).append(node)
|
|
87
|
+
for nodes in groups.values():
|
|
88
|
+
# Only nodes the emit loop will actually keep (visible) can be a shadow
|
|
89
|
+
# or a winner — judging by all nodes lets an invisible winner erase a
|
|
90
|
+
# visible row (the winner is then dropped by Pass 2's visibility filter).
|
|
91
|
+
visible_nodes = [n for n in nodes if n.attrib.get("visible") == "true"]
|
|
92
|
+
if len(visible_nodes) < 2:
|
|
93
|
+
continue # 0/1 visible element with this label — nothing to dedup
|
|
94
|
+
top = max(_interactive_priority(_ios_type(n)) for n in visible_nodes)
|
|
95
|
+
if top < 1:
|
|
96
|
+
continue # no visible interactive element — keep them all
|
|
97
|
+
for node in visible_nodes:
|
|
98
|
+
if _interactive_priority(_ios_type(node)) < top:
|
|
99
|
+
suppress.add(id(node))
|
|
100
|
+
return suppress
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def compress_ios_xml(raw_xml: str) -> str:
|
|
104
|
+
try:
|
|
105
|
+
root = ET.fromstring(raw_xml)
|
|
106
|
+
except ET.ParseError as e:
|
|
107
|
+
raw_preview = raw_xml[:200] if raw_xml else "(empty)"
|
|
108
|
+
print(f"[Warning] iOS XML parse failed: {e}, first 200 chars: {raw_preview}")
|
|
109
|
+
return '{"ui_elements": []}'
|
|
110
|
+
|
|
111
|
+
elements = []
|
|
112
|
+
seen_keys = set()
|
|
113
|
+
has_keyboard = False
|
|
114
|
+
# Pass 1: within each row (Cell), find label-shadow StaticTexts that merely
|
|
115
|
+
# repeat the row control's label, so Pass 2 emits ONE targetable element per
|
|
116
|
+
# row instead of a Button/Cell/Switch + its inert text twin. `root` stays alive
|
|
117
|
+
# across both passes (id()-keyed), so don't re-parse between them.
|
|
118
|
+
shadow_ids = _compute_label_shadows(root)
|
|
119
|
+
|
|
120
|
+
for node in root.iter():
|
|
121
|
+
if id(node) in shadow_ids:
|
|
122
|
+
continue # redundant label-shadow — the row's actionable control carries it.
|
|
123
|
+
|
|
124
|
+
attrib = node.attrib
|
|
125
|
+
label = attrib.get("label", "").strip()
|
|
126
|
+
name = attrib.get("name", "").strip()
|
|
127
|
+
value = attrib.get("value", "").strip()
|
|
128
|
+
node_type = attrib.get("type", "").replace("XCUIElementType", "")
|
|
129
|
+
enabled = attrib.get("enabled") == "true"
|
|
130
|
+
visible = attrib.get("visible") == "true"
|
|
131
|
+
accessible = attrib.get("accessible") == "true"
|
|
132
|
+
|
|
133
|
+
if not visible:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if not (label or name or value or accessible):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if node_type in _SKIP_TYPES:
|
|
140
|
+
if not label:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if node_type == "Other" and not label:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if node_type in _KEYBOARD_TYPES:
|
|
147
|
+
has_keyboard = True
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
if node_type == "WebView" and not label:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
if label and ("滚动条" in label or "scroll bar" in label.lower()):
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if node_type == "StaticText" and label and len(label) <= _NOISE_LABEL_MAX_LEN:
|
|
157
|
+
if label.isdigit() or label in (".", ","):
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Flat exact-dedup (pre-existing): collapse repeated (type, label, name),
|
|
161
|
+
# except Cells (each Cell is its own row). Note its interaction with the
|
|
162
|
+
# shadow pass above: two SEPARATE rows whose interactive controls share an
|
|
163
|
+
# identical (type, label, name) collapse to a single element here, and their
|
|
164
|
+
# StaticText shadows were already suppressed — so identically-named rows are
|
|
165
|
+
# not independently targetable. Benign for real WDA, where each row's control
|
|
166
|
+
# carries a unique name (e.g. com.apple.settings.general); it only affects
|
|
167
|
+
# synthetic identical-name rows, which d(label=...) could not disambiguate
|
|
168
|
+
# even before de-shadowing. Pinned by test_duplicate_name_rows_* in
|
|
169
|
+
# tests/test_utils_ios.py.
|
|
170
|
+
dedup_key = (node_type, label, name)
|
|
171
|
+
if dedup_key in seen_keys and node_type != "Cell":
|
|
172
|
+
continue
|
|
173
|
+
seen_keys.add(dedup_key)
|
|
174
|
+
|
|
175
|
+
el_info = {"type": node_type}
|
|
176
|
+
if label:
|
|
177
|
+
el_info["label"] = label
|
|
178
|
+
if name and name != label:
|
|
179
|
+
el_info["name"] = name
|
|
180
|
+
if value:
|
|
181
|
+
el_info["value"] = value
|
|
182
|
+
if accessible:
|
|
183
|
+
el_info["accessible"] = True
|
|
184
|
+
# Unified cross-platform key: a disabled control is `disabled: true`,
|
|
185
|
+
# matching Android (utils_xml.py) and Web (utils_web.py), so the LLM brain
|
|
186
|
+
# sees one vocabulary for "can't interact" on every platform.
|
|
187
|
+
if not enabled:
|
|
188
|
+
el_info["disabled"] = True
|
|
189
|
+
|
|
190
|
+
elements.append(el_info)
|
|
191
|
+
|
|
192
|
+
if has_keyboard:
|
|
193
|
+
elements.append({"type": "Keyboard", "label": "keyboard_visible"})
|
|
194
|
+
|
|
195
|
+
return json.dumps({"ui_elements": elements}, ensure_ascii=False)
|