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.
@@ -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.7",
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()