opendevice 0.1.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 (67) hide show
  1. opendevice/__init__.py +45 -0
  2. opendevice/__main__.py +6 -0
  3. opendevice/ax/__init__.py +58 -0
  4. opendevice/ax/ax.py +492 -0
  5. opendevice/ax/dtx.py +502 -0
  6. opendevice/cli/__init__.py +141 -0
  7. opendevice/cli/apps.py +303 -0
  8. opendevice/cli/commands.py +346 -0
  9. opendevice/cli/common.py +140 -0
  10. opendevice/cli/device.py +158 -0
  11. opendevice/cli/media.py +426 -0
  12. opendevice/cli/system.py +200 -0
  13. opendevice/cli/wireless.py +116 -0
  14. opendevice/core/__init__.py +26 -0
  15. opendevice/core/console.py +34 -0
  16. opendevice/core/errors.py +45 -0
  17. opendevice/core/lock.py +256 -0
  18. opendevice/core/log.py +130 -0
  19. opendevice/core/output.py +91 -0
  20. opendevice/core/paths.py +124 -0
  21. opendevice/core/plistio.py +45 -0
  22. opendevice/input/__init__.py +24 -0
  23. opendevice/input/hid.py +428 -0
  24. opendevice/input/pasteboard.py +92 -0
  25. opendevice/media/__init__.py +42 -0
  26. opendevice/media/media.py +173 -0
  27. opendevice/media/rtp.py +239 -0
  28. opendevice/media/stream.py +160 -0
  29. opendevice/py.typed +0 -0
  30. opendevice/remote/__init__.py +41 -0
  31. opendevice/remote/coredevice.py +323 -0
  32. opendevice/remote/http2.py +79 -0
  33. opendevice/remote/ipstack.py +368 -0
  34. opendevice/remote/remotexpc.py +114 -0
  35. opendevice/remote/rsd.py +120 -0
  36. opendevice/remote/tunnel.py +251 -0
  37. opendevice/remote/xpc.py +206 -0
  38. opendevice/services/__init__.py +23 -0
  39. opendevice/services/afc.py +356 -0
  40. opendevice/services/amfi.py +111 -0
  41. opendevice/services/base.py +72 -0
  42. opendevice/services/diagnostics.py +68 -0
  43. opendevice/services/installation.py +190 -0
  44. opendevice/services/mounter.py +761 -0
  45. opendevice/services/syslog.py +88 -0
  46. opendevice/services/tss.py +209 -0
  47. opendevice/session/__init__.py +16 -0
  48. opendevice/session/controls.py +123 -0
  49. opendevice/session/display.py +99 -0
  50. opendevice/session/link.py +228 -0
  51. opendevice/session/services.py +126 -0
  52. opendevice/session/tree.py +61 -0
  53. opendevice/session/video.py +240 -0
  54. opendevice/transport/__init__.py +16 -0
  55. opendevice/transport/devices.py +140 -0
  56. opendevice/transport/lockdown.py +112 -0
  57. opendevice/transport/pairing.py +149 -0
  58. opendevice/transport/service.py +128 -0
  59. opendevice/transport/usbmux.py +181 -0
  60. opendevice/wireless/__init__.py +19 -0
  61. opendevice/wireless/rrp.py +757 -0
  62. opendevice/wireless/wifi.py +188 -0
  63. opendevice-0.1.0.dist-info/METADATA +336 -0
  64. opendevice-0.1.0.dist-info/RECORD +67 -0
  65. opendevice-0.1.0.dist-info/WHEEL +4 -0
  66. opendevice-0.1.0.dist-info/entry_points.txt +2 -0
  67. opendevice-0.1.0.dist-info/licenses/LICENSE +21 -0
opendevice/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from .core.errors import (
4
+ AfcError,
5
+ DeveloperModeError,
6
+ InstallError,
7
+ LockdownError,
8
+ MuxError,
9
+ NotPairedError,
10
+ NotSupportedError,
11
+ OpenDeviceError,
12
+ ServiceError,
13
+ TunnelError,
14
+ )
15
+ from .remote.tunnel import Tunnel, TunnelInfo
16
+ from .transport.lockdown import LockdownClient
17
+ from .transport.pairing import PairingRecord
18
+ from .transport.service import ServiceConnection
19
+ from .transport.usbmux import Mux, MuxDevice, connect_port, list_devices
20
+
21
+ __version__ = "0.1.0"
22
+ __all__ = [
23
+ "LockdownClient", "PairingRecord", "ServiceConnection",
24
+ "Tunnel", "TunnelInfo",
25
+ "Mux", "MuxDevice", "connect_port", "list_devices",
26
+ "OpenDeviceError", "MuxError", "NotPairedError", "ServiceError",
27
+ "LockdownError", "TunnelError", "AfcError", "InstallError",
28
+ "DeveloperModeError", "NotSupportedError",
29
+ "remote", "services", "media", "ax",
30
+ "core", "transport", "wireless", "input",
31
+ ]
32
+
33
+ _SUBPACKAGES = ("ax", "core", "input", "media", "remote", "services",
34
+ "transport", "wireless")
35
+
36
+
37
+ def __getattr__(name: str) -> object:
38
+ 'PEP 562: only import ``opendevice.services`` when the attribute is touched.'
39
+ if name in _SUBPACKAGES:
40
+ import importlib
41
+
42
+ module = importlib.import_module(f"{__name__}.{name}")
43
+ globals()[name] = module
44
+ return module
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
opendevice/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from . import ax, dtx
4
+ from .ax import (
5
+ ATTR_CHILDREN,
6
+ ATTR_CHILDREN_NAV,
7
+ ATTR_DESC,
8
+ ATTR_ENABLED,
9
+ ATTR_FRAME,
10
+ ATTR_FRAME_SCREEN,
11
+ ATTR_KIND,
12
+ ATTR_LABEL,
13
+ ATTR_PLACEHOLDER,
14
+ ATTR_ROLE_DESC,
15
+ ATTR_TRAITS,
16
+ ATTR_USER_LABELS,
17
+ ATTR_VALUE,
18
+ SERVICE_AX,
19
+ AXClient,
20
+ AXElement,
21
+ class_name,
22
+ find,
23
+ find_all,
24
+ format_tree,
25
+ parse_elements,
26
+ parse_payload,
27
+ parse_rect,
28
+ to_tree,
29
+ trait_names,
30
+ tree_nodes,
31
+ unwrap_payload,
32
+ )
33
+ from .dtx import (
34
+ PNULL,
35
+ DTXConnection,
36
+ DTXMessage,
37
+ PBuf,
38
+ PInt32,
39
+ PInt64,
40
+ PNull,
41
+ PStr,
42
+ archive_object,
43
+ build_aux,
44
+ parse_aux,
45
+ unarchive_objects,
46
+ )
47
+
48
+ __all__ = [
49
+ "ax", "dtx",
50
+ "AXClient", "AXElement", "class_name", "find", "find_all", "format_tree",
51
+ "parse_elements", "parse_payload", "parse_rect", "to_tree", "trait_names", "tree_nodes",
52
+ "unwrap_payload",
53
+ "ATTR_CHILDREN", "ATTR_CHILDREN_NAV", "ATTR_DESC", "ATTR_ENABLED", "ATTR_FRAME",
54
+ "ATTR_FRAME_SCREEN", "ATTR_KIND", "ATTR_LABEL", "ATTR_PLACEHOLDER", "ATTR_ROLE_DESC",
55
+ "ATTR_TRAITS", "ATTR_USER_LABELS", "ATTR_VALUE", "SERVICE_AX",
56
+ "DTXConnection", "DTXMessage", "archive_object", "unarchive_objects", "build_aux",
57
+ "parse_aux", "PBuf", "PInt32", "PInt64", "PNULL", "PNull", "PStr",
58
+ ]
opendevice/ax/ax.py ADDED
@@ -0,0 +1,492 @@
1
+ from __future__ import annotations
2
+
3
+ import plistlib
4
+ import re
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ from ..core.errors import OpenDeviceError
9
+ from ..remote.rsd import RSD
10
+ from .dtx import DISPATCH, DTXConnection
11
+
12
+ SERVICE_AX = "com.apple.accessibility.axAuditDaemon.remoteAXService"
13
+
14
+ ATTR_LABEL = "3"
15
+ ATTR_VALUE = "64"
16
+ ATTR_KIND = "45"
17
+ ATTR_FRAME = "21"
18
+ ATTR_FRAME_SCREEN = "48"
19
+ ATTR_CHILDREN = "9"
20
+ ATTR_CHILDREN_NAV = "8"
21
+ ATTR_TRAITS = "77"
22
+ ATTR_ENABLED = "31"
23
+ ATTR_DESC = "33"
24
+ ATTR_USER_LABELS = "87"
25
+ ATTR_ROLE_DESC = "78"
26
+ ATTR_PLACEHOLDER = "42"
27
+
28
+ KIND_NAMES = {1: 'Window', 2: 'Button', 4: 'Tab', 14: 'Text'}
29
+
30
+ STALE_DUMP_SIZE = 4000
31
+
32
+ #: One fetch is several payloads: ``InitialDump`` opens a snapshot, ``AdditionalData`` fills it in.
33
+ DUMP_INITIAL = "AXPTreeDumpTypeInitialDump"
34
+ DUMP_ADDITIONAL = "AXPTreeDumpTypeAdditionalData"
35
+
36
+ #: How long the stream has to stay quiet before a dump is considered finished.
37
+ QUIET_WINDOW = 1.1
38
+
39
+ #: How long one poll of the socket may block; also the tail overshoot after the last payload.
40
+ POLL_TICK = 0.1
41
+
42
+ _RECT_RE = re.compile(r"\{\{([-\d.eE+]+),\s*([-\d.eE+]+)\},\s*\{([-\d.eE+]+),\s*([-\d.eE+]+)\}\}")
43
+
44
+
45
+ def parse_rect(value: Any) -> Optional[tuple[float, float, float, float]]:
46
+ "``'{ {x, y}, {w, h} }'`` -> (x, y, w, h)."
47
+ if not isinstance(value, str):
48
+ return None
49
+ match = _RECT_RE.search(value)
50
+ if not match:
51
+ return None
52
+ try:
53
+ x, y, w, h = (float(g) for g in match.groups())
54
+ except ValueError:
55
+ return None
56
+ return x, y, w, h
57
+
58
+
59
+ def _text_of(value: Any) -> str:
60
+ 'Coerce an attribute into a string (an array contributes its first non-empty entry).'
61
+ if isinstance(value, (list, tuple)):
62
+ value = next((item for item in value if item), "")
63
+ return "" if value is None else str(value)
64
+
65
+
66
+ def _role_text(value: Any) -> str:
67
+ 'Join a role-description array into one ``·``-separated string.'
68
+ if isinstance(value, (list, tuple)):
69
+ return "·".join(str(item) for item in value if item)
70
+ return "" if value is None else str(value)
71
+
72
+
73
+ class AXElement:
74
+ 'One accessibility element.'
75
+
76
+ __slots__ = ("object_id", "pid", "label", "description", "value", "kind_code",
77
+ "frame", "children", "traits", "enabled", "user_labels", "role",
78
+ "placeholder", "raw")
79
+
80
+ def __init__(self, object_id: int, pid: Optional[int], raw: dict[str, Any]) -> None:
81
+ self.object_id = object_id
82
+ self.pid = pid
83
+ self.raw = raw
84
+ self.label = _text_of(raw.get(ATTR_LABEL))
85
+ self.description = _text_of(raw.get(ATTR_DESC))
86
+ self.value = _text_of(raw.get(ATTR_VALUE))
87
+ self.placeholder = _text_of(raw.get(ATTR_PLACEHOLDER))
88
+ self.role = _role_text(raw.get(ATTR_ROLE_DESC))
89
+ self.kind_code = raw.get(ATTR_KIND)
90
+ self.traits = raw.get(ATTR_TRAITS)
91
+ self.enabled = raw.get(ATTR_ENABLED)
92
+ labels = raw.get(ATTR_USER_LABELS)
93
+ self.user_labels = labels if isinstance(labels, list) else []
94
+ self.frame = parse_rect(raw.get(ATTR_FRAME_SCREEN)) or parse_rect(raw.get(ATTR_FRAME))
95
+ kids = raw.get(ATTR_CHILDREN) or []
96
+ self.children: list[int] = [
97
+ c[1] for c in kids
98
+ if isinstance(c, tuple) and len(c) > 1 and isinstance(c[1], int)
99
+ ]
100
+
101
+ @property
102
+ def x(self) -> Optional[float]:
103
+ return self.frame[0] if self.frame else None
104
+
105
+ @property
106
+ def y(self) -> Optional[float]:
107
+ return self.frame[1] if self.frame else None
108
+
109
+ @property
110
+ def width(self) -> Optional[float]:
111
+ return self.frame[2] if self.frame else None
112
+
113
+ @property
114
+ def height(self) -> Optional[float]:
115
+ return self.frame[3] if self.frame else None
116
+
117
+ @property
118
+ def center(self) -> Optional[tuple[float, float]]:
119
+ if not self.frame:
120
+ return None
121
+ x, y, w, h = self.frame
122
+ return x + w / 2.0, y + h / 2.0
123
+
124
+ @property
125
+ def text(self) -> str:
126
+ return self.label or self.description or self.value or self.placeholder or ""
127
+
128
+ @property
129
+ def kind(self) -> str:
130
+ 'Element kind name; falls back to the first device role description.'
131
+ name = KIND_NAMES.get(self.kind_code, "") if isinstance(self.kind_code, int) else ""
132
+ if name:
133
+ return name
134
+ return self.role.split("·")[0] if self.role else ""
135
+
136
+ def __repr__(self) -> str:
137
+ return f"<AXElement {self.text!r} {self.frame}>"
138
+
139
+
140
+ def class_name(objects: list[Any], obj: Any) -> Optional[str]:
141
+ 'Read ``$classname`` of an NSKeyedArchive object.'
142
+ if not isinstance(obj, dict):
143
+ return None
144
+ cls = obj.get("$class")
145
+ index = cls.data if isinstance(cls, plistlib.UID) else None
146
+ if index is not None and isinstance(objects[index], dict):
147
+ name = objects[index].get("$classname")
148
+ return name if isinstance(name, str) else None
149
+ return None
150
+
151
+
152
+ def unwrap_payload(payload: bytes, depth: int = 0) -> bytes:
153
+ 'Unwrap ``NSMutableData`` / ``NSData`` to get the real response table.'
154
+ if depth > 4:
155
+ return payload
156
+ try:
157
+ objects = plistlib.loads(payload)["$objects"]
158
+ except Exception:
159
+ return payload
160
+ if any(class_name(objects, obj) == "AXPTranslatorResponse" for obj in objects):
161
+ return payload
162
+ for obj in objects:
163
+ if isinstance(obj, dict) and isinstance(obj.get("NS.data"), bytes):
164
+ return unwrap_payload(obj["NS.data"], depth + 1)
165
+ return payload
166
+
167
+
168
+ class _Archive:
169
+ 'Decode the NSKeyedArchive by hand (there is no ready class map for AXPTranslatorResponse).'
170
+
171
+ def __init__(self, data: bytes) -> None:
172
+ self.objs: list[Any] = plistlib.loads(data)["$objects"]
173
+
174
+ def _class_name(self, obj: dict[str, Any]) -> Optional[str]:
175
+ return class_name(self.objs, obj)
176
+
177
+ def value(self, v: Any, depth: int = 0) -> Any:
178
+ if depth > 12:
179
+ return None
180
+ if isinstance(v, plistlib.UID):
181
+ v = self.objs[v.data]
182
+ if isinstance(v, dict):
183
+ name = self._class_name(v)
184
+ if name == "AXPTranslationObject":
185
+ return ("ELEM", self.value(v.get("objectID"), depth + 1),
186
+ self.value(v.get("pid"), depth + 1))
187
+ if name == "NSValue":
188
+ return self.value(v.get("NS.rectval"), depth + 1)
189
+ if name and "String" in name:
190
+ return self.value(v.get("NS.string") or v.get("NSString"), depth + 1)
191
+ if "Array" in (name or ""):
192
+ return [self.value(i, depth + 1) for i in (v.get("NS.objects") or [])]
193
+ if "Data" in (name or ""):
194
+ return self.value(v.get("NS.data"), depth + 1)
195
+ if name in ("NSDictionary", "NSMutableDictionary"):
196
+ keys = v.get("NS.keys") or []
197
+ vals = v.get("NS.objects") or []
198
+ return {str(self.value(k, depth + 1)): self.value(x, depth + 1)
199
+ for k, x in zip(keys, vals)}
200
+ return None
201
+ if isinstance(v, (list, tuple)):
202
+ return [self.value(i, depth + 1) for i in v]
203
+ if isinstance(v, bytes):
204
+ try:
205
+ return v.decode("utf-8")
206
+ except Exception:
207
+ return f"<data {len(v)}>"
208
+ return v
209
+
210
+ def responses(self):
211
+ for obj in self.objs:
212
+ if isinstance(obj, dict) and self._class_name(obj) == "AXPTranslatorResponse":
213
+ yield obj
214
+
215
+
216
+ def parse_payload(payload: bytes) -> tuple[str, list[AXElement]]:
217
+ 'Decode one AXPTranslatorResponse payload into ``(dump type, elements)``.'
218
+ archive = _Archive(unwrap_payload(payload))
219
+ return _dump_type(archive), _elements_of(archive)
220
+
221
+
222
+ def _dump_type(archive: _Archive) -> str:
223
+ 'The ``treeDumpType`` carried by this payload (empty when it has none).'
224
+ for response in archive.responses():
225
+ data = archive.value(response.get("resultData"))
226
+ if isinstance(data, dict) and "treeDumpType" in data:
227
+ return _text_of(archive.value(data.get("treeDumpType")))
228
+ return ""
229
+
230
+
231
+ def _elements_of(archive: _Archive) -> list[AXElement]:
232
+ 'Every element described by the ``attribute == 0`` responses of one archive.'
233
+ elements: dict[int, AXElement] = {}
234
+ for response in archive.responses():
235
+ if str(archive.value(response.get("attribute"))) != "0":
236
+ continue
237
+ handle = archive.value(response.get("associatedTranslationObject"))
238
+ data = archive.value(response.get("resultData"))
239
+ if not (isinstance(handle, tuple) and isinstance(data, dict)):
240
+ continue
241
+ if _capability_table(data):
242
+ continue
243
+ object_id = handle[1]
244
+ if not isinstance(object_id, int):
245
+ continue
246
+ old = elements.get(object_id)
247
+ if old is None:
248
+ elements[object_id] = AXElement(object_id, handle[2], data)
249
+ else:
250
+ merged = _merge_attrs(old.raw, data)
251
+ elements[object_id] = AXElement(object_id, handle[2] or old.pid, merged)
252
+ return list(elements.values())
253
+
254
+
255
+ def parse_elements(payload: bytes) -> list[AXElement]:
256
+ 'Decode all elements from one AXPTranslatorResponse payload.'
257
+ return parse_payload(payload)[1]
258
+
259
+
260
+ def _merge_attrs(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
261
+ 'Merge attribute dicts: ``extra`` overrides ``base``, but keys whose value is ``None`` do not.'
262
+ return {**base, **{key: value for key, value in extra.items()
263
+ if value is not None}}
264
+
265
+
266
+ def _capability_table(data: dict[str, Any]) -> bool:
267
+ 'The "supported attributes" bit table inside AdditionalData: keys are attribute ids and values are all bools, not attributes.'
268
+ return len(data) > 20 and all(isinstance(value, bool) for value in data.values())
269
+
270
+
271
+ class AXClient:
272
+ 'Fetch the control tree of the current screen (with coordinates).'
273
+
274
+ def __init__(self, rsd: RSD, service_name: str = SERVICE_AX) -> None:
275
+ self.rsd = rsd
276
+ self.service_name = service_name
277
+ self.dtx: Optional[DTXConnection] = None
278
+ self.last_len = 0
279
+ self.stats: dict[str, int] = {"messages": 0, "payloads": 0, "rounds": 0}
280
+
281
+ def connect(self) -> "AXClient":
282
+ self.close()
283
+ port = self.rsd.service_port(self.service_name)
284
+ conn = self.rsd.stack.connect(port)
285
+ self.dtx = DTXConnection(conn)
286
+ self.dtx.handshake()
287
+ return self
288
+
289
+ def close(self) -> None:
290
+ if self.dtx is not None:
291
+ self.dtx.close()
292
+ self.dtx = None
293
+
294
+ def __enter__(self) -> "AXClient":
295
+ return self.connect()
296
+
297
+ def __exit__(self, *exc: Any) -> None:
298
+ self.close()
299
+
300
+ @property
301
+ def stale(self) -> bool:
302
+ 'Whether the latest payload was a truncated dump.'
303
+ return 0 < self.last_len < STALE_DUMP_SIZE
304
+
305
+ def fetch(self, timeout: float = 15.0, fresh: bool = True,
306
+ quiet: float = QUIET_WINDOW) -> list[AXElement]:
307
+ 'Trigger one dump, collect until it goes quiet, and return the last complete round.'
308
+ if fresh or self.dtx is None:
309
+ self.connect()
310
+ if self.dtx is None:
311
+ raise OpenDeviceError('AX connection was not established (connect() failed)')
312
+ self.dtx.incoming.clear()
313
+
314
+ self.dtx.invoke("clientNeedsAccessibility:", (True,), channel_code=0,
315
+ expects_reply=True, timeout=8.0, ignore_errors=True)
316
+
317
+ return self._collect(timeout, quiet)
318
+
319
+ def _collect(self, timeout: float, quiet: float) -> list[AXElement]:
320
+ 'Collect payloads until the stream goes quiet, keeping only the latest dump round.'
321
+ if self.dtx is None:
322
+ raise OpenDeviceError('AX connection was not established (connect() failed)')
323
+ deadline = time.monotonic() + timeout
324
+ elements: dict[int, AXElement] = {}
325
+ cursor = 0
326
+ last_packet = 0.0
327
+ filled = False # the current round already received its AdditionalData
328
+ while time.monotonic() < deadline and (not last_packet
329
+ or time.monotonic() - last_packet < quiet):
330
+ while cursor < len(self.dtx.incoming):
331
+ payload = _payload_of(self.dtx.incoming[cursor])
332
+ cursor += 1
333
+ self.stats["messages"] += 1
334
+ if payload is None:
335
+ continue
336
+ kind, parsed = parse_payload(payload)
337
+ if kind == DUMP_INITIAL and filled:
338
+ # A fresh snapshot starts: whatever the previous round held was transitional.
339
+ elements.clear()
340
+ filled = False
341
+ self.stats["rounds"] += 1
342
+ if kind == DUMP_ADDITIONAL:
343
+ filled = True
344
+ self.stats["payloads"] += 1
345
+ self.last_len = len(payload)
346
+ self.stats["last_len"] = len(payload)
347
+ last_packet = time.monotonic()
348
+ for element in parsed:
349
+ old = elements.get(element.object_id)
350
+ if old is None:
351
+ elements[element.object_id] = element
352
+ else:
353
+ merged = _merge_attrs(old.raw, element.raw)
354
+ elements[element.object_id] = AXElement(
355
+ element.object_id, element.pid or old.pid, merged)
356
+ if last_packet and time.monotonic() - last_packet >= quiet:
357
+ break
358
+ self.dtx.pump(POLL_TICK)
359
+ return list(elements.values())
360
+
361
+
362
+ def _payload_of(message: Any) -> Optional[bytes]:
363
+ 'Is this DISPATCH a ``processDataFromRemoteDevice:`` dump? If so, extract the bytes.'
364
+ if message.type != DISPATCH or message.name != "processDataFromRemoteDevice:":
365
+ return None
366
+ for arg in message.aux:
367
+ if isinstance(arg, (bytes, bytearray)) and len(arg) > 1000:
368
+ return bytes(arg)
369
+ return None
370
+
371
+
372
+ def find(elements: list[AXElement], text: str,
373
+ exact: bool = False) -> Optional[AXElement]:
374
+ 'Find an element by text (substring by default; returns the first hit that has a frame).'
375
+ for element in elements:
376
+ haystack = element.text
377
+ hit = (haystack == text) if exact else (text in haystack)
378
+ if hit and element.center:
379
+ return element
380
+ return None
381
+
382
+
383
+ def find_all(elements: list[AXElement], text: str) -> list[AXElement]:
384
+ return [el for el in elements if text in el.text and el.center]
385
+
386
+
387
+ def to_tree(elements: list[AXElement]) -> list[tuple[int, AXElement]]:
388
+ 'Flatten into ``[(depth, element)]`` for printing.'
389
+ return [(depth, element) for depth, _last, element in tree_nodes(elements)]
390
+
391
+
392
+ def tree_nodes(elements: list[AXElement]) -> list[tuple[int, bool, AXElement]]:
393
+ '``[(depth, is_last_child_of_parent, element)]``.'
394
+ by_id = {el.object_id: el for el in elements}
395
+ child_ids = {c for el in elements for c in el.children}
396
+ roots = [el for el in elements if el.object_id not in child_ids]
397
+ out: list[tuple[int, bool, AXElement]] = []
398
+ seen: set[int] = set()
399
+
400
+ def walk(element: AXElement, depth: int, last: bool) -> None:
401
+ if element.object_id in seen or depth > 20:
402
+ return
403
+ seen.add(element.object_id)
404
+ out.append((depth, last, element))
405
+ kids = [by_id[c] for c in element.children if c in by_id]
406
+ for index, child in enumerate(kids):
407
+ walk(child, depth + 1, index == len(kids) - 1)
408
+
409
+ for index, root in enumerate(roots or elements):
410
+ walk(root, 0, index == len(roots or elements) - 1)
411
+ return out
412
+
413
+
414
+ TRAIT_NAMES = [(1, 'Button'), (1024, 'SearchField'), (2, 'Link'), (65536, 'Header'), (64, 'Text'),
415
+ (4, 'Image'), (4096, 'Adjustable'), (8, 'Selected'), (256, 'Disabled')]
416
+
417
+ TEXT_COLUMNS = 44
418
+
419
+
420
+ def trait_names(element: AXElement) -> list[str]:
421
+ 'Which traits this element carries (in the order above).'
422
+ traits = element.traits if isinstance(element.traits, int) else 0
423
+ return [name for bit, name in TRAIT_NAMES if traits & bit]
424
+
425
+
426
+ def format_tree(elements: list[AXElement], limit: int = 40,
427
+ contains: Optional[str] = None) -> str:
428
+ 'Render the controls as a tree: tree lines + frame + text + traits.'
429
+ from ..core import console
430
+
431
+ nodes = tree_nodes(elements)
432
+ if contains:
433
+ keep = _hit_and_ancestors(nodes, contains)
434
+ nodes = [node for node in nodes if node[2].object_id in keep]
435
+ shown, rest = nodes[:limit], nodes[limit:]
436
+ columns = max((console.width(_frame(node[2])) for node in shown), default=1)
437
+
438
+ lines: list[str] = []
439
+ branch: list[bool] = []
440
+ for depth, last, element in shown:
441
+ branch = branch[:depth]
442
+ lines.append(_branch(branch, last) + _cells(element, columns))
443
+ branch.append(last)
444
+ if rest:
445
+ lines.append(f"... {len(rest)} more (--limit {limit} stops here)")
446
+ return "\n".join(lines)
447
+
448
+
449
+ def _branch(ancestors: list[bool], last: bool) -> str:
450
+ 'Tree lines: ``| `` continues, ``+- `` branches, ``\\- `` closes.'
451
+ trunk = "".join("│ " if not done else " " for done in ancestors)
452
+ return trunk + ("└─ " if last else "├─ ")
453
+
454
+
455
+ def _cells(element: AXElement, columns: int) -> str:
456
+ 'Everything after the tree line on one row: frame (aligned) + text + traits.'
457
+ from ..core import console
458
+
459
+ text = console.clip(_label(element), TEXT_COLUMNS)
460
+ tags = "·".join(name for name in trait_names(element)
461
+ if name not in ('Text', 'Image'))
462
+ tail = f" [{tags}]" if tags else ""
463
+ return f"{console.pad(_frame(element), columns)} {text}{tail}".rstrip()
464
+
465
+
466
+ def _frame(element: AXElement) -> str:
467
+ '``(x,y wxh)``.'
468
+ if not element.frame:
469
+ return "—"
470
+ return (f"({element.x:.0f},{element.y:.0f} "
471
+ f"{element.width:.0f}x{element.height:.0f})")
472
+
473
+
474
+ def _label(element: AXElement) -> str:
475
+ 'Display name: text > a user_labels fallback > the kind name (identities like ``<Button>``).'
476
+ text = " ".join((element.text or "").split())
477
+ if not text and element.user_labels:
478
+ text = " ".join(str(element.user_labels[0]).split())
479
+ if text:
480
+ return text
481
+ return f"<{element.kind}>" if element.kind else "·"
482
+
483
+
484
+ def _hit_and_ancestors(nodes: list[tuple[int, bool, AXElement]], text: str) -> set[int]:
485
+ 'The matched element **plus every ancestor** (otherwise a filtered tree is not a tree).'
486
+ keep: set[int] = set()
487
+ stack: list[int] = []
488
+ for depth, _last, element in nodes:
489
+ stack = stack[:depth] + [element.object_id]
490
+ if text.lower() in element.text.lower():
491
+ keep.update(stack)
492
+ return keep