agent-device 0.11.7 → 0.11.8
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.
- package/dist/src/bin.js +1 -1
- package/dist/src/daemon.js +45 -45
- package/dist/src/index.d.ts +2 -2
- package/package.json +4 -2
- package/src/platforms/linux/atspi-dump.py +285 -0
package/dist/src/index.d.ts
CHANGED
|
@@ -246,7 +246,7 @@ declare type CliFlags = {
|
|
|
246
246
|
sessionLock?: 'reject' | 'strip';
|
|
247
247
|
sessionLocked?: boolean;
|
|
248
248
|
sessionLockConflicts?: 'reject' | 'strip';
|
|
249
|
-
platform?: 'ios' | 'macos' | 'android' | 'apple';
|
|
249
|
+
platform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple';
|
|
250
250
|
target?: 'mobile' | 'tv' | 'desktop';
|
|
251
251
|
device?: string;
|
|
252
252
|
udid?: string;
|
|
@@ -498,7 +498,7 @@ declare type MetroRuntimeHints = {
|
|
|
498
498
|
launchUrl?: string;
|
|
499
499
|
};
|
|
500
500
|
|
|
501
|
-
declare type Platform = ApplePlatform | 'android';
|
|
501
|
+
declare type Platform = ApplePlatform | 'android' | 'linux';
|
|
502
502
|
|
|
503
503
|
declare type PlatformSelector = Platform | 'apple';
|
|
504
504
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-device",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.8",
|
|
4
4
|
"description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Callstack",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"test:replay:ios": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator",
|
|
46
46
|
"test:replay:ios-device": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/device",
|
|
47
47
|
"test:replay:android": "node --experimental-strip-types src/bin.ts test test/integration/replays/android",
|
|
48
|
-
"test:replay:macos": "node --experimental-strip-types src/bin.ts test test/integration/replays/macos"
|
|
48
|
+
"test:replay:macos": "node --experimental-strip-types src/bin.ts test test/integration/replays/macos",
|
|
49
|
+
"test:replay:linux": "node --experimental-strip-types src/bin.ts test test/integration/replays/linux"
|
|
49
50
|
},
|
|
50
51
|
"files": [
|
|
51
52
|
"bin",
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
"!ios-runner/**/*.xcuserstate",
|
|
58
59
|
"macos-helper",
|
|
59
60
|
"!macos-helper/**/.build",
|
|
61
|
+
"src/platforms/linux/atspi-dump.py",
|
|
60
62
|
"skills",
|
|
61
63
|
"README.md",
|
|
62
64
|
"LICENSE"
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AT-SPI2 accessibility tree dumper.
|
|
4
|
+
|
|
5
|
+
Traverses the AT-SPI2 accessibility tree and outputs JSON to stdout.
|
|
6
|
+
Used by agent-device's Linux platform support as a subprocess.
|
|
7
|
+
|
|
8
|
+
Requires: python3-gi, gir1.2-atspi-2.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import gi
|
|
15
|
+
gi.require_version("Atspi", "2.0")
|
|
16
|
+
from gi.repository import Atspi # noqa: E402
|
|
17
|
+
|
|
18
|
+
MAX_NODES = 1500
|
|
19
|
+
MAX_DEPTH = 12
|
|
20
|
+
MAX_DESKTOP_APPS = 24
|
|
21
|
+
|
|
22
|
+
VALID_SURFACES = ("desktop", "frontmost-app")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_rect(accessible):
|
|
26
|
+
try:
|
|
27
|
+
component = accessible.get_component_iface()
|
|
28
|
+
if not component:
|
|
29
|
+
return None
|
|
30
|
+
extents = component.get_extents(Atspi.CoordType.SCREEN)
|
|
31
|
+
if not extents:
|
|
32
|
+
return None
|
|
33
|
+
if extents.width <= 0 or extents.height <= 0:
|
|
34
|
+
return None
|
|
35
|
+
return {
|
|
36
|
+
"x": extents.x,
|
|
37
|
+
"y": extents.y,
|
|
38
|
+
"width": extents.width,
|
|
39
|
+
"height": extents.height,
|
|
40
|
+
}
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_text_value(accessible):
|
|
46
|
+
try:
|
|
47
|
+
text_iface = accessible.get_text_iface()
|
|
48
|
+
if not text_iface:
|
|
49
|
+
return None
|
|
50
|
+
count = text_iface.get_character_count()
|
|
51
|
+
if count <= 0:
|
|
52
|
+
return None
|
|
53
|
+
value = text_iface.get_text(0, count)
|
|
54
|
+
return value if value else None
|
|
55
|
+
except Exception:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_numeric_value(accessible):
|
|
60
|
+
try:
|
|
61
|
+
value_iface = accessible.get_value_iface()
|
|
62
|
+
if not value_iface:
|
|
63
|
+
return None
|
|
64
|
+
current = value_iface.get_current_value()
|
|
65
|
+
if current is None:
|
|
66
|
+
return None
|
|
67
|
+
return str(current)
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def has_state(state_set, state_type):
|
|
73
|
+
try:
|
|
74
|
+
return state_set.contains(state_type)
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def traverse_node(accessible, depth, parent_index, ctx, app_info, window_title=None):
|
|
80
|
+
if len(ctx["nodes"]) >= ctx["max_nodes"] or depth > ctx["max_depth"] or not accessible:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
role_name = accessible.get_role_name() or "unknown"
|
|
85
|
+
except Exception:
|
|
86
|
+
role_name = "unknown"
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
name = accessible.get_name() or ""
|
|
90
|
+
except Exception:
|
|
91
|
+
name = ""
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
description = accessible.get_description() or ""
|
|
95
|
+
except Exception:
|
|
96
|
+
description = ""
|
|
97
|
+
|
|
98
|
+
label = name or description or None
|
|
99
|
+
rect = get_rect(accessible)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
state_set = accessible.get_state_set()
|
|
103
|
+
except Exception:
|
|
104
|
+
state_set = None
|
|
105
|
+
|
|
106
|
+
enabled = has_state(state_set, Atspi.StateType.ENABLED) if state_set else None
|
|
107
|
+
selected = has_state(state_set, Atspi.StateType.SELECTED) if state_set else None
|
|
108
|
+
visible = has_state(state_set, Atspi.StateType.VISIBLE) if state_set else True
|
|
109
|
+
showing = has_state(state_set, Atspi.StateType.SHOWING) if state_set else True
|
|
110
|
+
hittable = (enabled is not False) and visible and showing and (rect is not None)
|
|
111
|
+
|
|
112
|
+
current_window_title = window_title
|
|
113
|
+
if current_window_title is None and role_name in ("frame", "window", "dialog"):
|
|
114
|
+
current_window_title = label
|
|
115
|
+
|
|
116
|
+
nodes = ctx["nodes"]
|
|
117
|
+
node_index = len(nodes)
|
|
118
|
+
value = get_text_value(accessible) or get_numeric_value(accessible)
|
|
119
|
+
|
|
120
|
+
node = {
|
|
121
|
+
"index": node_index,
|
|
122
|
+
"role": role_name,
|
|
123
|
+
"label": label,
|
|
124
|
+
"value": value,
|
|
125
|
+
"rect": rect,
|
|
126
|
+
"enabled": enabled,
|
|
127
|
+
"selected": selected,
|
|
128
|
+
"hittable": hittable,
|
|
129
|
+
"depth": depth,
|
|
130
|
+
"parentIndex": parent_index,
|
|
131
|
+
"pid": app_info.get("pid"),
|
|
132
|
+
"appName": app_info.get("appName"),
|
|
133
|
+
"windowTitle": current_window_title,
|
|
134
|
+
}
|
|
135
|
+
nodes.append(node)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
child_count = accessible.get_child_count()
|
|
139
|
+
except Exception:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
for i in range(child_count):
|
|
143
|
+
if len(nodes) >= ctx["max_nodes"]:
|
|
144
|
+
break
|
|
145
|
+
try:
|
|
146
|
+
child = accessible.get_child_at_index(i)
|
|
147
|
+
if child:
|
|
148
|
+
traverse_node(
|
|
149
|
+
child, depth + 1, node_index, ctx, app_info,
|
|
150
|
+
current_window_title
|
|
151
|
+
)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def find_focused_application(desktop, app_count):
|
|
157
|
+
for i in range(app_count):
|
|
158
|
+
try:
|
|
159
|
+
app = desktop.get_child_at_index(i)
|
|
160
|
+
if not app:
|
|
161
|
+
continue
|
|
162
|
+
child_count = app.get_child_count()
|
|
163
|
+
for j in range(child_count):
|
|
164
|
+
try:
|
|
165
|
+
win = app.get_child_at_index(j)
|
|
166
|
+
if not win:
|
|
167
|
+
continue
|
|
168
|
+
state_set = win.get_state_set()
|
|
169
|
+
if state_set and has_state(state_set, Atspi.StateType.ACTIVE):
|
|
170
|
+
return app
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# Fallback: first app with children
|
|
177
|
+
for i in range(app_count):
|
|
178
|
+
try:
|
|
179
|
+
app = desktop.get_child_at_index(i)
|
|
180
|
+
if app and app.get_child_count() > 0:
|
|
181
|
+
return app
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_app_info(app):
|
|
188
|
+
try:
|
|
189
|
+
app_name = app.get_name() or None
|
|
190
|
+
except Exception:
|
|
191
|
+
app_name = None
|
|
192
|
+
try:
|
|
193
|
+
pid = app.get_process_id()
|
|
194
|
+
except Exception:
|
|
195
|
+
pid = None
|
|
196
|
+
return {"appName": app_name, "pid": pid}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def capture(surface, max_nodes=MAX_NODES, max_depth=MAX_DEPTH, max_apps=MAX_DESKTOP_APPS):
|
|
200
|
+
desktop = Atspi.get_desktop(0)
|
|
201
|
+
if not desktop:
|
|
202
|
+
return {"error": "Could not get desktop accessible. Is the accessibility bus running?"}
|
|
203
|
+
|
|
204
|
+
app_count = desktop.get_child_count()
|
|
205
|
+
ctx = {"nodes": [], "max_nodes": max_nodes, "max_depth": max_depth}
|
|
206
|
+
|
|
207
|
+
if surface == "frontmost-app":
|
|
208
|
+
focused = find_focused_application(desktop, app_count)
|
|
209
|
+
if focused:
|
|
210
|
+
traverse_node(focused, 0, None, ctx, get_app_info(focused))
|
|
211
|
+
else:
|
|
212
|
+
apps_to_traverse = min(app_count, max_apps)
|
|
213
|
+
for i in range(apps_to_traverse):
|
|
214
|
+
if len(ctx["nodes"]) >= max_nodes:
|
|
215
|
+
break
|
|
216
|
+
try:
|
|
217
|
+
app = desktop.get_child_at_index(i)
|
|
218
|
+
if not app or app.get_child_count() == 0:
|
|
219
|
+
continue
|
|
220
|
+
traverse_node(app, 0, None, ctx, get_app_info(app))
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
nodes = ctx["nodes"]
|
|
225
|
+
return {
|
|
226
|
+
"nodes": nodes,
|
|
227
|
+
"truncated": len(nodes) >= max_nodes,
|
|
228
|
+
"surface": surface,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def parse_int_arg(value, name):
|
|
233
|
+
try:
|
|
234
|
+
n = int(value)
|
|
235
|
+
if n < 0:
|
|
236
|
+
raise ValueError(f"negative value")
|
|
237
|
+
return n
|
|
238
|
+
except ValueError as e:
|
|
239
|
+
json.dump({"error": f"Invalid value for {name}: '{value}' ({e})"}, sys.stdout)
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main():
|
|
244
|
+
try:
|
|
245
|
+
surface = "desktop"
|
|
246
|
+
max_nodes = MAX_NODES
|
|
247
|
+
max_depth = MAX_DEPTH
|
|
248
|
+
max_apps = MAX_DESKTOP_APPS
|
|
249
|
+
|
|
250
|
+
args = sys.argv[1:]
|
|
251
|
+
i = 0
|
|
252
|
+
while i < len(args):
|
|
253
|
+
if args[i] == "--surface" and i + 1 < len(args):
|
|
254
|
+
surface = args[i + 1]
|
|
255
|
+
i += 2
|
|
256
|
+
elif args[i] == "--max-nodes" and i + 1 < len(args):
|
|
257
|
+
max_nodes = parse_int_arg(args[i + 1], "--max-nodes")
|
|
258
|
+
i += 2
|
|
259
|
+
elif args[i] == "--max-depth" and i + 1 < len(args):
|
|
260
|
+
max_depth = parse_int_arg(args[i + 1], "--max-depth")
|
|
261
|
+
i += 2
|
|
262
|
+
elif args[i] == "--max-apps" and i + 1 < len(args):
|
|
263
|
+
max_apps = parse_int_arg(args[i + 1], "--max-apps")
|
|
264
|
+
i += 2
|
|
265
|
+
else:
|
|
266
|
+
i += 1
|
|
267
|
+
|
|
268
|
+
if surface not in VALID_SURFACES:
|
|
269
|
+
json.dump(
|
|
270
|
+
{"error": f"Unknown surface '{surface}'. Valid: {', '.join(VALID_SURFACES)}"},
|
|
271
|
+
sys.stdout,
|
|
272
|
+
)
|
|
273
|
+
sys.exit(1)
|
|
274
|
+
|
|
275
|
+
result = capture(surface, max_nodes, max_depth, max_apps)
|
|
276
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
277
|
+
except SystemExit:
|
|
278
|
+
raise
|
|
279
|
+
except Exception as e:
|
|
280
|
+
json.dump({"error": f"Unexpected error: {e}"}, sys.stdout)
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
main()
|