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.
- opendevice/__init__.py +45 -0
- opendevice/__main__.py +6 -0
- opendevice/ax/__init__.py +58 -0
- opendevice/ax/ax.py +492 -0
- opendevice/ax/dtx.py +502 -0
- opendevice/cli/__init__.py +141 -0
- opendevice/cli/apps.py +303 -0
- opendevice/cli/commands.py +346 -0
- opendevice/cli/common.py +140 -0
- opendevice/cli/device.py +158 -0
- opendevice/cli/media.py +426 -0
- opendevice/cli/system.py +200 -0
- opendevice/cli/wireless.py +116 -0
- opendevice/core/__init__.py +26 -0
- opendevice/core/console.py +34 -0
- opendevice/core/errors.py +45 -0
- opendevice/core/lock.py +256 -0
- opendevice/core/log.py +130 -0
- opendevice/core/output.py +91 -0
- opendevice/core/paths.py +124 -0
- opendevice/core/plistio.py +45 -0
- opendevice/input/__init__.py +24 -0
- opendevice/input/hid.py +428 -0
- opendevice/input/pasteboard.py +92 -0
- opendevice/media/__init__.py +42 -0
- opendevice/media/media.py +173 -0
- opendevice/media/rtp.py +239 -0
- opendevice/media/stream.py +160 -0
- opendevice/py.typed +0 -0
- opendevice/remote/__init__.py +41 -0
- opendevice/remote/coredevice.py +323 -0
- opendevice/remote/http2.py +79 -0
- opendevice/remote/ipstack.py +368 -0
- opendevice/remote/remotexpc.py +114 -0
- opendevice/remote/rsd.py +120 -0
- opendevice/remote/tunnel.py +251 -0
- opendevice/remote/xpc.py +206 -0
- opendevice/services/__init__.py +23 -0
- opendevice/services/afc.py +356 -0
- opendevice/services/amfi.py +111 -0
- opendevice/services/base.py +72 -0
- opendevice/services/diagnostics.py +68 -0
- opendevice/services/installation.py +190 -0
- opendevice/services/mounter.py +761 -0
- opendevice/services/syslog.py +88 -0
- opendevice/services/tss.py +209 -0
- opendevice/session/__init__.py +16 -0
- opendevice/session/controls.py +123 -0
- opendevice/session/display.py +99 -0
- opendevice/session/link.py +228 -0
- opendevice/session/services.py +126 -0
- opendevice/session/tree.py +61 -0
- opendevice/session/video.py +240 -0
- opendevice/transport/__init__.py +16 -0
- opendevice/transport/devices.py +140 -0
- opendevice/transport/lockdown.py +112 -0
- opendevice/transport/pairing.py +149 -0
- opendevice/transport/service.py +128 -0
- opendevice/transport/usbmux.py +181 -0
- opendevice/wireless/__init__.py +19 -0
- opendevice/wireless/rrp.py +757 -0
- opendevice/wireless/wifi.py +188 -0
- opendevice-0.1.0.dist-info/METADATA +336 -0
- opendevice-0.1.0.dist-info/RECORD +67 -0
- opendevice-0.1.0.dist-info/WHEEL +4 -0
- opendevice-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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
|