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.
Files changed (64) hide show
  1. cli/__init__.py +0 -0
  2. cli/_version.py +1 -0
  3. cli/dispatch.py +266 -0
  4. cli/doctor.py +487 -0
  5. cli/modes/__init__.py +0 -0
  6. cli/modes/action.py +262 -0
  7. cli/modes/default.py +248 -0
  8. cli/modes/demo.py +162 -0
  9. cli/modes/dry_run.py +237 -0
  10. cli/modes/init.py +133 -0
  11. cli/modes/plan.py +148 -0
  12. cli/modes/workflow.py +354 -0
  13. cli/parser.py +305 -0
  14. cli/reporter.py +207 -0
  15. cli/session.py +146 -0
  16. cli/shared.py +427 -0
  17. cli/shorthand.py +90 -0
  18. cli/tool_protocol_handlers.py +446 -0
  19. common/__init__.py +0 -0
  20. common/adapters/__init__.py +21 -0
  21. common/adapters/android_adapter.py +273 -0
  22. common/adapters/base_adapter.py +24 -0
  23. common/adapters/ios_adapter.py +278 -0
  24. common/adapters/web_adapter.py +271 -0
  25. common/ai.py +277 -0
  26. common/ai_autonomous.py +273 -0
  27. common/ai_heal.py +222 -0
  28. common/cache/__init__.py +15 -0
  29. common/cache/cache_hash.py +57 -0
  30. common/cache/cache_manager.py +300 -0
  31. common/cache/cache_stats.py +133 -0
  32. common/cache/cache_storage.py +79 -0
  33. common/cache/embedding_loader.py +150 -0
  34. common/capabilities.py +121 -0
  35. common/case_memory.py +327 -0
  36. common/error_codes.py +61 -0
  37. common/exceptions.py +18 -0
  38. common/executor.py +1504 -0
  39. common/failure_diagnosis.py +138 -0
  40. common/history_manager.py +75 -0
  41. common/logs.py +168 -0
  42. common/mcp_server.py +467 -0
  43. common/preflight.py +496 -0
  44. common/progress.py +37 -0
  45. common/run_reporter.py +415 -0
  46. common/run_resume.py +149 -0
  47. common/runtime_modes.py +35 -0
  48. common/tool_protocol.py +196 -0
  49. common/visual_fallback.py +71 -0
  50. common/workflow_schema.py +150 -0
  51. config/__init__.py +0 -0
  52. config/config.py +167 -0
  53. config/env_loader.py +76 -0
  54. screenforge-0.4.0.dist-info/METADATA +43 -0
  55. screenforge-0.4.0.dist-info/RECORD +64 -0
  56. screenforge-0.4.0.dist-info/WHEEL +5 -0
  57. screenforge-0.4.0.dist-info/entry_points.txt +2 -0
  58. screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
  59. screenforge-0.4.0.dist-info/top_level.txt +4 -0
  60. utils/__init__.py +0 -0
  61. utils/screenshot_annotator.py +60 -0
  62. utils/utils_ios.py +195 -0
  63. utils/utils_web.py +304 -0
  64. 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ screenforge = cli.dispatch:main
@@ -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.
@@ -0,0 +1,4 @@
1
+ cli
2
+ common
3
+ config
4
+ utils
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)