staysfixed 0.11.0 → 0.12.0

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.
@@ -0,0 +1,1028 @@
1
+ /**
2
+ * Reading a native Linux desktop app, over the machine somebody already has.
3
+ *
4
+ * This file knows about the Linux accessibility bus and nothing about Stays Fixed. It finds
5
+ * the desktop session on a machine reached over ssh, opens the app, reads the whole window
6
+ * tree, and hands back plain objects. `linux.js` turns those into observations. Same split as
7
+ * `ios.js`/`ios-driver.js` and for the same reason: everything hard here is a fact about the
8
+ * platform, and platform facts are easier to keep honest when they live in one file.
9
+ *
10
+ * ── WHAT WAS MEASURED BEFORE ANY OF THIS WAS WRITTEN ───────────────────────────────────────
11
+ *
12
+ * All of it on a stock Ubuntu 24.04 machine on 2026-08-31, with NOTHING installed for the
13
+ * purpose. This is the sibling of `windows.js`, and it was costed the same way.
14
+ *
15
+ * 1. THE ACCESSIBILITY SERVICE IS ALREADY THERE. `at-spi2-core` and `gdbus` come with any
16
+ * desktop Linux; nothing has to be installed to read a window. The address of the
17
+ * accessibility bus is one call:
18
+ * gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus \
19
+ * --method org.a11y.Bus.GetAddress
20
+ * 2. `python3` AND THE `dbus` MODULE ARE ALREADY THERE — checked on two separate machines.
21
+ * So the probe is Python, sent down the ssh connection each run, living only in the
22
+ * memory of the process running it, leaving nothing on that machine's disk.
23
+ * 3. ONE PROGRAM DOING THE WHOLE WALK IN-PROCESS IS THE DESIGN, and it is the only design.
24
+ * In one Python process: 190 accessible things in 305 ms — and 189 nodes read with every
25
+ * property, state, action and value in 562-707 ms across four runs. Shelling out to
26
+ * `gdbus` once per property instead: 30 things in 1343 ms. That is not slower, it is
27
+ * unusable. For scale, the Windows adapter reads 148 controls in 303 ms. The two
28
+ * platforms cost about the same.
29
+ * 4. THE CONNECTION IS THE ONE THIS TOOL ALREADY HAS. A held ssh link to that machine
30
+ * answers a request in 150-250 ms, and the handshake costs 3.2 seconds once. Measured
31
+ * against the demo box on 2026-08-31 through `remote.js` unchanged, so this adapter adds
32
+ * no transport of its own.
33
+ *
34
+ * ── THREE TRAPS THAT COST REAL TIME, WRITTEN DOWN SO NOBODY PAYS AGAIN ─────────────────────
35
+ *
36
+ * A. `dbus.connection.Connection(addr)` FAILS WITH `NoReply`. It opens the socket and never
37
+ * performs the bus handshake, so the first method call sits there until it times out. The
38
+ * class that does the handshake is `dbus.bus.BusConnection(addr)`. One character of
39
+ * difference, twenty minutes of looking at the wrong end.
40
+ *
41
+ * B. `gdbus` PRINTS THE FIRST ARRAY ENTRY DIFFERENTLY FROM EVERY LATER ONE. Asking the
42
+ * registry for its children prints the first as `(':1.0', objectpath '/path')` and the
43
+ * rest as `(':1.0', '/path')`. A regex that expects the word `objectpath` every time reads
44
+ * a tree SIX NODES DEEP instead of 190 and reports it with total confidence — which is
45
+ * precisely the shape of bug this whole tool exists to catch. `parseGdbusChildren` below
46
+ * handles both forms and there is a test holding it down.
47
+ *
48
+ * C. THE BULK QUERY ANSWERS ZERO, IN ZERO MILLISECONDS, AND IS WRONG. Every application on
49
+ * the accessibility bus advertises `org.a11y.atspi.Collection`, whose `GetMatches` is meant
50
+ * to return a whole subtree in one call — the natural fast path, and the exact equivalent
51
+ * of the cached read the Windows adapter uses. On the machine this was built against it
52
+ * returned 0 objects for all five match modes, in 0 ms, while a plain walk of the same
53
+ * application found 189 nodes. An adapter that had trusted it would have recorded "this app
54
+ * has no controls", and every later run would have compared zero against zero and agreed
55
+ * that nothing changed. So it is NOT USED, and the cross-check below exists because of it.
56
+ *
57
+ * ── HOW THE READ IS CROSS-CHECKED ─────────────────────────────────────────────────────────
58
+ *
59
+ * Every node is read twice by two different routes: the `GetChildren` method, which builds a
60
+ * list of children, and the `ChildCount` property, which counts them by another code path
61
+ * inside the same bridge. Both come back in the walk that is happening anyway, so the check
62
+ * is free. When they disagree the tree is lying about its own shape and the read is reported
63
+ * as unchecked rather than as an answer. That is a genuine second opinion about the SHAPE of
64
+ * the tree; it is not a second opinion about the CONTENTS of a node, and this file does not
65
+ * claim one. Measured on the demo box: 189 nodes, 0 disagreements.
66
+ */
67
+
68
+ import { RemoteLinkLost } from '../remote.js';
69
+
70
+ /**
71
+ * What every reply from the Python probe starts with.
72
+ *
73
+ * The far side is a Python program running inside a shell inside an ssh session, and all
74
+ * three of those write to the same stream when they feel like it: a deprecation warning from
75
+ * a library, a GTK message about a missing theme, an at-spi warning about a stale reference.
76
+ * Every one of those turned up during the work that produced this file. So a line either
77
+ * starts with this and is a reply, or it does not and is kept as noise a person can read.
78
+ * Same rule as `remote.js` uses one layer up, for the same reason.
79
+ */
80
+ export const PROBE_SENTINEL = '#SFPY#';
81
+
82
+ /**
83
+ * The accessibility states, in the order the bus numbers them.
84
+ *
85
+ * This is `AtspiStateType` from at-spi2-core, and the order is fixed by the wire protocol —
86
+ * a state arrives as two 32-bit numbers with one bit per entry in this list, so the position
87
+ * IS the meaning. Checked against real readings on 2026-08-31: a GTK frame came back as
88
+ * 0x43200102, which is bits 1, 8, 21, 24, 25 and 30 — active, enabled, resizable, sensitive,
89
+ * showing, visible. That is exactly what a window on screen should say, and it is how this
90
+ * list was proved right rather than assumed.
91
+ */
92
+ export const STATE_NAMES = [
93
+ 'invalid', 'active', 'armed', 'busy', 'checked', 'collapsed', 'defunct', 'editable',
94
+ 'enabled', 'expandable', 'expanded', 'focusable', 'focused', 'has tooltip', 'horizontal',
95
+ 'iconified', 'modal', 'multi line', 'multiselectable', 'opaque', 'pressed', 'resizable',
96
+ 'selectable', 'selected', 'sensitive', 'showing', 'single line', 'stale', 'transient',
97
+ 'vertical', 'visible', 'manages descendants', 'indeterminate', 'required', 'truncated',
98
+ 'animated', 'invalid entry', 'supports autocompletion', 'selectable text', 'is default',
99
+ 'visited', 'checkable', 'has popup', 'read only',
100
+ ];
101
+
102
+ /**
103
+ * The states that are compared, and therefore the ones a change in will be reported.
104
+ *
105
+ * Everything a person would call behaviour is in here: whether a control works, whether it is
106
+ * ticked, whether it is open, whether it is chosen, whether it can be typed into, whether it
107
+ * is the default. What is deliberately LEFT OUT is the handful that change with where the
108
+ * mouse is and which window happens to be in front — `focused`, `active`, `busy`, `stale`,
109
+ * `transient`, `opaque`, `visited`. Those flip between two readings of an unchanged app, and
110
+ * comparing them would fill a report with movement and bury the one real difference in it.
111
+ *
112
+ * This is an ALLOW list rather than a block list on purpose. A version of at-spi that adds a
113
+ * new state cannot then start flapping the report on its own; it arrives as something not yet
114
+ * compared, which is a decision somebody makes on purpose rather than a surprise.
115
+ */
116
+ export const COMPARED_STATES = new Set([
117
+ 'armed', 'checkable', 'checked', 'collapsed', 'defunct', 'editable', 'enabled', 'expandable',
118
+ 'expanded', 'focusable', 'has popup', 'has tooltip', 'horizontal', 'iconified',
119
+ 'indeterminate', 'invalid entry', 'is default', 'modal', 'multi line', 'multiselectable',
120
+ 'pressed', 'read only', 'required', 'selectable', 'selectable text', 'selected', 'sensitive',
121
+ 'showing', 'single line', 'supports autocompletion', 'truncated', 'vertical', 'visible',
122
+ ]);
123
+
124
+ /**
125
+ * The roles that mean "this is a window somebody is looking at".
126
+ *
127
+ * An application on the accessibility bus has windows as its children, and everything else
128
+ * hanging off it — menus that have never been opened, tooltips, off-screen popups — is not a
129
+ * window. Reading those as windows produces a report full of things nobody has seen.
130
+ */
131
+ export const WINDOW_ROLES = new Set(['frame', 'dialog', 'window', 'alert', 'file chooser']);
132
+
133
+ /**
134
+ * The name of the environment variable that marks everything this tool started.
135
+ *
136
+ * The Windows probe keeps a list of the process ids it launched, in memory, and refuses to
137
+ * stop anything else. This does the same job and does it better, because it survives the
138
+ * probe exiting: every process this run starts inherits this variable, so "is this ours" is a
139
+ * question the machine itself answers, out of `/proc`, at any later moment. Somebody's real
140
+ * work is on that desktop and this is the line that protects it.
141
+ */
142
+ export const RUN_MARKER = 'STAYSFIXED_RUN';
143
+
144
+ /** Past this many nodes the walk stops and says it stopped, rather than reading for ever. */
145
+ export const MAX_TREE_NODES = 4000;
146
+
147
+ /** A window picture over this size is dropped rather than carried back down the ssh link. */
148
+ export const MAX_SHOT_BYTES = 1_500_000;
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // The cheap probe: is there an accessibility bus here at all
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * A shell one-liner that answers "is there a desktop with an accessibility bus on this
156
+ * machine" using only what a desktop Linux already has.
157
+ *
158
+ * `gdbus` and nothing else. This exists so `detect` and `doctor` can answer the question on a
159
+ * machine that is missing the Python `dbus` module — which is the one piece that might not be
160
+ * there — instead of reporting "no desktop" at something that has one. Detect, never ask.
161
+ *
162
+ * @param {{display?: string, sessionBus?: string}} [env] What the caller already knows.
163
+ * @returns {string} A command for `/bin/sh -c`.
164
+ */
165
+ export function gdbusProbeCommand(env = {}) {
166
+ const prelude = [
167
+ env.display ? `export DISPLAY='${env.display}'` : '',
168
+ env.sessionBus ? `export DBUS_SESSION_BUS_ADDRESS='${env.sessionBus}'` : '',
169
+ ].filter(Boolean).join('; ');
170
+ const body = [
171
+ // The screen question comes FIRST and gates everything after it. See the note in the probe
172
+ // about a screenless machine: asking a bare user session for the accessibility bus starts
173
+ // an empty one, and then reports it as a desktop.
174
+ "SCREEN=no; for f in /proc/[0-9]*/environ; do if tr '\\0' '\\n' < \"$f\" 2>/dev/null |"
175
+ + " grep -qE '^(DISPLAY|WAYLAND_DISPLAY)=.'; then SCREEN=yes; break; fi; done",
176
+ (env.display ? 'SCREEN=yes' : 'true'),
177
+ 'echo "SCREEN $SCREEN"',
178
+ 'if [ "$SCREEN" != yes ]; then exit 0; fi',
179
+ 'ADDR=$(gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus'
180
+ + ' --method org.a11y.Bus.GetAddress 2>&1)',
181
+ 'echo "ADDRESS $ADDR"',
182
+ 'BUS=$(printf %s "$ADDR" | sed "s/^(.//; s/.,)$//")',
183
+ 'gdbus call --address "$BUS" --dest org.a11y.atspi.Registry'
184
+ + ' --object-path /org/a11y/atspi/accessible/root'
185
+ + ' --method org.a11y.atspi.Accessible.GetChildren 2>&1 | sed "s/^/APPS /"',
186
+ ].join('; ');
187
+ return prelude ? `${prelude}; ${body}` : body;
188
+ }
189
+
190
+ /**
191
+ * Pull the accessibility bus address out of what `gdbus` printed.
192
+ *
193
+ * `gdbus` prints a one-string reply as `('unix:path=/run/user/0/at-spi/bus_99',)`, and prints
194
+ * a failure as a sentence on the same stream. Returning the sentence as if it were an address
195
+ * would send the next call at a socket path made of English, so anything that is not an
196
+ * address comes back as null and the caller says the bus was not found.
197
+ *
198
+ * @param {string} text Whatever the command printed.
199
+ * @returns {string|null}
200
+ */
201
+ export function parseGdbusAddress(text) {
202
+ const line = text.split('\n').map((l) => l.trim()).find((l) => l.startsWith('ADDRESS ')) ?? text;
203
+ const found = /'((?:unix:|tcp:)[^']*)'/.exec(line);
204
+ return found ? found[1] : null;
205
+ }
206
+
207
+ /**
208
+ * Pull the list of applications out of what `gdbus` printed for `GetChildren`.
209
+ *
210
+ * TRAP B, AND THE WHOLE REASON THIS IS A FUNCTION WITH A TEST RATHER THAN A REGEX INLINE.
211
+ * `gdbus` writes the FIRST entry of an array of `(so)` pairs with its type spelled out and
212
+ * every later one without it:
213
+ *
214
+ * ([(':1.0', objectpath '/org/a11y/atspi/accessible/root'), (':1.4', '/org/...root')],)
215
+ *
216
+ * A pattern that requires `objectpath` matches only the first entry, so a desktop running six
217
+ * applications reads as one. Nothing errors, nothing is empty, and the answer is wrong — a
218
+ * quiet, confident, wrong answer, which is the failure this product exists to prevent. So the
219
+ * word is optional here, and `test/v2/linux.test.js` holds it down with both shapes.
220
+ *
221
+ * @param {string} text
222
+ * @returns {{bus: string, path: string}[]}
223
+ */
224
+ export function parseGdbusChildren(text) {
225
+ /** @type {{bus: string, path: string}[]} */
226
+ const out = [];
227
+ const pair = /\('([^']*)',\s*(?:objectpath\s*)?'([^']*)'\)/g;
228
+ let hit = pair.exec(text);
229
+ while (hit !== null) {
230
+ out.push({ bus: hit[1], path: hit[2] });
231
+ hit = pair.exec(text);
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * @typedef {object} DesktopProbe
238
+ * @property {boolean} hasBus An accessibility bus answered.
239
+ * @property {string|null} address Where it is, when it answered.
240
+ * @property {number} apps How many applications are on it.
241
+ * @property {string} why Plain English, always filled in.
242
+ */
243
+
244
+ /**
245
+ * Read the cheap probe's output into an answer.
246
+ *
247
+ * @param {string} stdout
248
+ * @param {string} [stderr]
249
+ * @returns {DesktopProbe}
250
+ */
251
+ export function readDesktopProbe(stdout, stderr = '') {
252
+ const all = `${stdout}\n${stderr}`;
253
+ if (/^SCREEN no$/m.test(all)) {
254
+ return {
255
+ hasBus: false,
256
+ address: null,
257
+ apps: 0,
258
+ why: 'There is no screen on that machine, so there is no desktop on it and nothing to read. A machine can '
259
+ + 'have a perfectly good user session and no desktop; asking such a machine for an accessibility bus '
260
+ + 'starts an empty one, which would look exactly like an app with no controls, so the question was not '
261
+ + 'asked.',
262
+ };
263
+ }
264
+ const address = parseGdbusAddress(all);
265
+ if (!address) {
266
+ const complaint = all.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 2).join(' ');
267
+ return {
268
+ hasBus: false,
269
+ address: null,
270
+ apps: 0,
271
+ why: 'Nothing on that machine answered as an accessibility bus, which is what a desktop session '
272
+ + `publishes. ${complaint ? `It said: ${complaint}` : 'It said nothing at all.'}`,
273
+ };
274
+ }
275
+ const apps = parseGdbusChildren(all).length;
276
+ return {
277
+ hasBus: true,
278
+ address,
279
+ apps,
280
+ why: apps === 0
281
+ ? 'An accessibility bus is running on that machine but nothing at all is on it. That is normal on a desktop '
282
+ + 'with no windows open, and it is also what a machine with no desktop looks like the moment something '
283
+ + 'asks it this question. An app opened there would still be read.'
284
+ : `An accessibility bus is running on that machine with ${apps} application${apps === 1 ? '' : 's'} on it, `
285
+ + 'so a native Linux window can be opened there and read.',
286
+ };
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // The probe
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * The Python program that does one job on the Linux side and prints one line of JSON.
295
+ *
296
+ * WHY IT IS A NEW PROCESS EACH TIME rather than a conversation held open the way the Windows
297
+ * probe is. Python starts, imports `dbus` and connects to the bus in 113 ms, measured on the
298
+ * demo box; a whole walk costs 600. Paying 113 ms per request buys something worth more than
299
+ * it: there is no long-lived process of ours on somebody's desktop, nothing to leak if this
300
+ * end dies, and the app under test is not a child of anything we are holding open — which is
301
+ * what makes the exit code survive to be read later.
302
+ *
303
+ * WHAT IT REFUSES TO DO. It will not stop a process that does not carry this run's marker in
304
+ * its environment. Not "should not" — it reads `/proc/<pid>/environ` and returns a refusal.
305
+ * That desktop belongs to somebody.
306
+ *
307
+ * THE ONE THING IT DOES WRITE, AND WHY, AND WHERE IT GOES. The probe itself is never on that
308
+ * machine's disk. But the app it starts has to outlive it, and a program whose parent has gone
309
+ * is reparented to init — which takes its exit code with it, and "did it fall over" is one of
310
+ * the questions this adapter exists to answer. So the app runs under a four-word shell that
311
+ * records its exit code, and its output goes to two files beside it, in one folder under
312
+ * `/tmp`. Those three files are read when the app is closed and the folder is REMOVED in the
313
+ * same breath. Nothing this tool does is still on somebody's disk tomorrow.
314
+ *
315
+ * Written with `String.raw` so the backslashes in it are the ones Python sees.
316
+ *
317
+ * @returns {string} Python 3, ready to be base64ed onto the wire.
318
+ */
319
+ export function linuxProbeScript() {
320
+ return String.raw`
321
+ import sys, os, json, base64, time, subprocess, signal, glob, re
322
+
323
+ SENT = ${JSON.stringify(PROBE_SENTINEL)}
324
+ STATES = ${JSON.stringify(STATE_NAMES)}
325
+ WINDOW_ROLES = set(${JSON.stringify([...WINDOW_ROLES])})
326
+ MARKER = ${JSON.stringify(RUN_MARKER)}
327
+ A = 'org.a11y.atspi.Accessible'
328
+ P = 'org.freedesktop.DBus.Properties'
329
+
330
+ def emit(o):
331
+ sys.stdout.write(SENT + json.dumps(o, default=str) + chr(10))
332
+ sys.stdout.flush()
333
+
334
+ # --- finding the desktop session ------------------------------------------------------------
335
+ # Over ssh there is no session: no DISPLAY, no session bus, no accessibility bus. Every one of
336
+ # those lives in the environment of the processes the person's own login started. So the
337
+ # session is FOUND, out of /proc, rather than assumed - which is what makes this work on a
338
+ # real machine somebody is sitting at, and what makes it say so plainly on a server that has
339
+ # no desktop at all instead of reporting an empty screen.
340
+
341
+ def env_of(pid):
342
+ try:
343
+ with open('/proc/' + str(pid) + '/environ', 'rb') as fh:
344
+ raw = fh.read()
345
+ except Exception:
346
+ return {}
347
+ out = {}
348
+ for part in raw.split(b'\0'):
349
+ if b'=' in part:
350
+ k, v = part.split(b'=', 1)
351
+ try:
352
+ out[k.decode('utf-8', 'replace')] = v.decode('utf-8', 'replace')
353
+ except Exception:
354
+ pass
355
+ return out
356
+
357
+ def own_pids():
358
+ me = os.getuid()
359
+ for entry in glob.glob('/proc/[0-9]*'):
360
+ try:
361
+ if os.stat(entry).st_uid == me:
362
+ yield int(entry.rsplit('/', 1)[1])
363
+ except Exception:
364
+ pass
365
+
366
+ def find_session(req):
367
+ given = dict(req.get('env') or {})
368
+ if req.get('envFile'):
369
+ try:
370
+ for line in open(req['envFile']):
371
+ m = re.match(r"\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=['\"]?(.*?)['\"]?\s*$", line)
372
+ if m and m.group(1) not in given:
373
+ given[m.group(1)] = m.group(2)
374
+ except Exception:
375
+ pass
376
+ if given.get('DBUS_SESSION_BUS_ADDRESS'):
377
+ return given, 'the config named it'
378
+ seen = {}
379
+ for pid in own_pids():
380
+ e = env_of(pid)
381
+ addr = e.get('DBUS_SESSION_BUS_ADDRESS')
382
+ if not addr:
383
+ continue
384
+ if not (e.get('DISPLAY') or e.get('WAYLAND_DISPLAY')):
385
+ continue
386
+ key = (addr, e.get('DISPLAY', ''), e.get('WAYLAND_DISPLAY', ''), e.get('XDG_RUNTIME_DIR', ''))
387
+ seen[key] = seen.get(key, 0) + 1
388
+ if seen:
389
+ best = sorted(seen.items(), key=lambda kv: -kv[1])[0][0]
390
+ found = {'DBUS_SESSION_BUS_ADDRESS': best[0]}
391
+ if best[1]:
392
+ found['DISPLAY'] = best[1]
393
+ if best[2]:
394
+ found['WAYLAND_DISPLAY'] = best[2]
395
+ if best[3]:
396
+ found['XDG_RUNTIME_DIR'] = best[3]
397
+ found.update(given)
398
+ return found, 'it was read out of a running desktop process'
399
+ runtime = given.get('XDG_RUNTIME_DIR') or ('/run/user/' + str(os.getuid()))
400
+ if os.path.exists(runtime + '/bus'):
401
+ found = {'DBUS_SESSION_BUS_ADDRESS': 'unix:path=' + runtime + '/bus', 'XDG_RUNTIME_DIR': runtime}
402
+ found.update(given)
403
+ return found, 'the session bus socket was found in the run directory'
404
+ return given, 'no desktop session could be found on this machine'
405
+
406
+ def apply_session(session):
407
+ for k, v in session.items():
408
+ if v:
409
+ os.environ[k] = v
410
+
411
+ # --- the accessibility bus ------------------------------------------------------------------
412
+
413
+ def a11y_address():
414
+ import dbus
415
+ # TRAP A: dbus.connection.Connection opens the socket and never performs the bus
416
+ # handshake, so the first call hangs until it times out with NoReply. BusConnection is
417
+ # the one that says hello.
418
+ session = dbus.bus.BusConnection(os.environ['DBUS_SESSION_BUS_ADDRESS'])
419
+ obj = session.get_object('org.a11y.Bus', '/org/a11y/bus', introspect=False)
420
+ return str(dbus.Interface(obj, 'org.a11y.Bus').GetAddress())
421
+
422
+ def connect():
423
+ import dbus
424
+ return dbus.bus.BusConnection(a11y_address())
425
+
426
+ def state_names(pair):
427
+ low = int(pair[0]) if len(pair) > 0 else 0
428
+ high = int(pair[1]) if len(pair) > 1 else 0
429
+ out = []
430
+ for i, name in enumerate(STATES):
431
+ word = low if i < 32 else high
432
+ if word & (1 << (i % 32)):
433
+ out.append(name)
434
+ return out
435
+
436
+ # --- the walk -------------------------------------------------------------------------------
437
+ # One process, one connection, one recursive pass. See the measurements at the top of
438
+ # linux-driver.js for why it can never be a shell-out per property.
439
+
440
+ def read_tree(bus, dest, path, limit):
441
+ import dbus
442
+ nodes = []
443
+ trouble = {'shape': 0, 'unreadable': 0}
444
+
445
+ def obj(d, p):
446
+ return bus.get_object(d, p, introspect=False)
447
+
448
+ def one(d, p, depth):
449
+ if len(nodes) >= limit:
450
+ return
451
+ o = obj(d, p)
452
+ acc = dbus.Interface(o, A)
453
+ props = dbus.Interface(o, P)
454
+ try:
455
+ all_props = props.GetAll(A)
456
+ role = str(acc.GetRoleName())
457
+ states = state_names(acc.GetState())
458
+ ifaces = [str(x).replace('org.a11y.atspi.', '') for x in acc.GetInterfaces()]
459
+ except Exception as err:
460
+ trouble['unreadable'] += 1
461
+ nodes.append({'role': '?', 'name': '', 'depth': depth, 'states': [], 'can': [],
462
+ 'unreadable': str(err)[:120]})
463
+ return
464
+ actions = []
465
+ if 'Action' in ifaces:
466
+ try:
467
+ actions = [str(x[0]) for x in dbus.Interface(o, 'org.a11y.atspi.Action').GetActions()]
468
+ except Exception:
469
+ pass
470
+ value = None
471
+ if 'Value' in ifaces:
472
+ try:
473
+ value = float(props.Get('org.a11y.atspi.Value', 'CurrentValue'))
474
+ except Exception:
475
+ pass
476
+ text = None
477
+ secret = False
478
+ if 'Text' in ifaces and role in ('text', 'entry', 'password text', 'terminal', 'paragraph'):
479
+ try:
480
+ got = str(dbus.Interface(o, 'org.a11y.atspi.Text').GetText(0, 200))
481
+ # A password box is read for its LENGTH and never for its contents. Somebody
482
+ # else's password must not end up in a stored reference on this machine.
483
+ if role == 'password text' or 'password' in str(all_props.get('Name', '')).lower():
484
+ secret = True
485
+ text = str(len(got)) + ' characters'
486
+ else:
487
+ text = got
488
+ except Exception:
489
+ pass
490
+ kids = []
491
+ try:
492
+ kids = [(str(a), str(b)) for a, b in acc.GetChildren()]
493
+ except Exception as err:
494
+ trouble['unreadable'] += 1
495
+ # The free cross-check: the method that lists children and the property that counts
496
+ # them are two code paths inside the bridge, and they must agree.
497
+ try:
498
+ claimed = int(all_props.get('ChildCount', len(kids)))
499
+ except Exception:
500
+ claimed = len(kids)
501
+ if claimed != len(kids):
502
+ trouble['shape'] += 1
503
+ nodes.append({
504
+ 'role': role,
505
+ 'name': str(all_props.get('Name', '')),
506
+ 'describe': str(all_props.get('Description', '')),
507
+ 'id': str(all_props.get('AccessibleId', '')),
508
+ 'depth': depth,
509
+ 'states': states,
510
+ 'can': sorted(actions),
511
+ 'value': value,
512
+ 'text': text,
513
+ 'secret': secret,
514
+ 'kids': len(kids),
515
+ 'claimed': claimed,
516
+ })
517
+ if depth < 40:
518
+ for cd, cp in kids:
519
+ one(cd, cp, depth + 1)
520
+
521
+ one(dest, path, 0)
522
+ return nodes, trouble
523
+
524
+ def applications(bus):
525
+ import dbus
526
+ root = bus.get_object('org.a11y.atspi.Registry', '/org/a11y/atspi/accessible/root', introspect=False)
527
+ kids = [(str(a), str(b)) for a, b in dbus.Interface(root, A).GetChildren()]
528
+ daemon = bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus', introspect=False)
529
+ names = dbus.Interface(daemon, 'org.freedesktop.DBus')
530
+ out = []
531
+ for d, p in kids:
532
+ pid = None
533
+ try:
534
+ pid = int(names.GetConnectionUnixProcessID(d))
535
+ except Exception:
536
+ pass
537
+ name = ''
538
+ toolkit = ''
539
+ try:
540
+ o = bus.get_object(d, p, introspect=False)
541
+ props = dbus.Interface(o, P)
542
+ name = str(props.Get(A, 'Name'))
543
+ # WHAT DREW THIS WINDOW, straight from the app. This is the Linux equivalent of the
544
+ # Chromium window class the Windows adapter looks for: an Electron app says
545
+ # 'Chromium' here, and it is covered better and in pairs by the Electron adapter.
546
+ toolkit = str(props.Get('org.a11y.atspi.Application', 'ToolkitName'))
547
+ except Exception:
548
+ pass
549
+ out.append({'bus': d, 'path': p, 'pid': pid, 'name': name, 'toolkit': toolkit})
550
+ return out
551
+
552
+ def extents(bus, dest, path):
553
+ import dbus
554
+ try:
555
+ o = bus.get_object(dest, path, introspect=False)
556
+ # 0 is screen coordinates. Window coordinates would give every window 0,0 and make
557
+ # two windows on top of each other indistinguishable.
558
+ e = dbus.Interface(o, 'org.a11y.atspi.Component').GetExtents(dbus.UInt32(0))
559
+ return {'x': int(e[0]), 'y': int(e[1]), 'w': int(e[2]), 'h': int(e[3])}
560
+ except Exception:
561
+ return None
562
+
563
+ def marked_pids(run_id):
564
+ out = []
565
+ for pid in own_pids():
566
+ e = env_of(pid)
567
+ if e.get(MARKER) == run_id:
568
+ out.append({'pid': pid, 'ppid': parent_of(pid), 'cmd': cmdline(pid), 'name': procname(pid)})
569
+ return out
570
+
571
+ def parent_of(pid):
572
+ try:
573
+ # Field 4 of /proc/<pid>/stat, counting from one. Field 2 is the program name in
574
+ # brackets and it is allowed to contain spaces and brackets, so the split has to start
575
+ # after the LAST close bracket or a process called "sh (old)" shifts every field along.
576
+ raw = open('/proc/' + str(pid) + '/stat').read()
577
+ return int(raw[raw.rindex(')') + 1:].split()[1])
578
+ except Exception:
579
+ return 0
580
+
581
+ def cmdline(pid):
582
+ try:
583
+ with open('/proc/' + str(pid) + '/cmdline', 'rb') as fh:
584
+ return ' '.join(x.decode('utf-8', 'replace') for x in fh.read().split(b'\0') if x)
585
+ except Exception:
586
+ return ''
587
+
588
+ def procname(pid):
589
+ try:
590
+ return open('/proc/' + str(pid) + '/comm').read().strip()
591
+ except Exception:
592
+ return '?'
593
+
594
+ # --- pixels ----------------------------------------------------------------------------------
595
+ # Evidence only, never the thing compared. Four ways to try, in the order of what a desktop is
596
+ # most likely to already have, and an honest refusal when none of them is there. Nothing here
597
+ # is worth installing something for.
598
+
599
+ def grab(box):
600
+ try:
601
+ import gi
602
+ gi.require_version('Gdk', '3.0')
603
+ from gi.repository import Gdk
604
+ root = Gdk.get_default_root_window()
605
+ if root is None:
606
+ return {'ok': False, 'why': 'there is no screen to photograph on this session'}
607
+ x = max(0, int(box.get('x', 0)))
608
+ y = max(0, int(box.get('y', 0)))
609
+ w = min(int(box.get('w') or root.get_width()), root.get_width() - x)
610
+ h = min(int(box.get('h') or root.get_height()), root.get_height() - y)
611
+ if w <= 0 or h <= 0:
612
+ return {'ok': False, 'why': 'that window has no size on screen right now'}
613
+ pb = Gdk.pixbuf_get_from_window(root, x, y, w, h)
614
+ if pb is None:
615
+ return {'ok': False, 'why': 'the screen would not give up its pixels'}
616
+ if pb.get_width() > 1024:
617
+ from gi.repository import GdkPixbuf
618
+ scale = 1024.0 / pb.get_width()
619
+ pb = pb.scale_simple(1024, max(1, int(pb.get_height() * scale)), GdkPixbuf.InterpType.BILINEAR)
620
+ ok, buf = pb.save_to_bufferv('png', [], [])
621
+ px = pb.get_pixels()
622
+ lit = sum(1 for i in range(0, min(len(px), 60000), 101) if px[i] > 12)
623
+ return {'ok': bool(ok), 'w': w, 'h': h, 'lit': lit, 'how': 'the desktop toolkit',
624
+ 'png': base64.b64encode(bytes(buf)).decode('ascii')}
625
+ except Exception as err:
626
+ return {'ok': False, 'why': 'no way to photograph a window is installed here (' + str(err)[:90] + ')'}
627
+
628
+ # --- what it did ------------------------------------------------------------------------------
629
+
630
+ def snap_dir(top, limit=5000):
631
+ out = {}
632
+ if not os.path.isdir(top):
633
+ return out
634
+ n = 0
635
+ for base, dirs, files in os.walk(top):
636
+ for f in files:
637
+ full = os.path.join(base, f)
638
+ try:
639
+ out[os.path.relpath(full, top)] = os.path.getsize(full)
640
+ except Exception:
641
+ pass
642
+ n += 1
643
+ if n >= limit:
644
+ return out
645
+ return out
646
+
647
+ def sockets_of(pids):
648
+ inodes = {}
649
+ for pid in pids:
650
+ for fd in glob.glob('/proc/' + str(pid) + '/fd/*'):
651
+ try:
652
+ link = os.readlink(fd)
653
+ except Exception:
654
+ continue
655
+ if link.startswith('socket:['):
656
+ inodes[link[8:-1]] = pid
657
+ out = []
658
+ for table in ('/proc/net/tcp', '/proc/net/tcp6'):
659
+ try:
660
+ lines = open(table).read().split(chr(10))[1:]
661
+ except Exception:
662
+ continue
663
+ for line in lines:
664
+ bits = line.split()
665
+ if len(bits) < 10 or bits[9] not in inodes:
666
+ continue
667
+ out.append({'remote': hexaddr(bits[2]), 'state': bits[3]})
668
+ return out
669
+
670
+ def hexaddr(field):
671
+ host, port = field.split(':')
672
+ port = int(port, 16)
673
+ if len(host) == 8:
674
+ b = [int(host[i:i + 2], 16) for i in (6, 4, 2, 0)]
675
+ return '.'.join(str(x) for x in b) + ':' + str(port)
676
+ return '[' + host.lower() + ']:' + str(port)
677
+
678
+ # --- the one operation this run was asked for --------------------------------------------------
679
+
680
+ def main():
681
+ req = json.loads(base64.b64decode(sys.argv[1]).decode('utf-8')) if len(sys.argv) > 1 else {}
682
+ op = req.get('op', 'hello')
683
+ started = time.time()
684
+ session, how = find_session(req)
685
+ apply_session(session)
686
+ took = lambda: int((time.time() - started) * 1000)
687
+
688
+ if op == 'hello':
689
+ distro = ''
690
+ try:
691
+ for line in open('/etc/os-release'):
692
+ if line.startswith('PRETTY_NAME='):
693
+ distro = line.split('=', 1)[1].strip().strip('"')
694
+ except Exception:
695
+ pass
696
+ screen_found = bool(session.get('DISPLAY') or session.get('WAYLAND_DISPLAY'))
697
+ out = {'ok': True, 'op': op, 'session': session, 'how': how, 'distro': distro,
698
+ 'user': os.environ.get('USER') or str(os.getuid()), 'uid': os.getuid(),
699
+ 'screenFound': screen_found,
700
+ 'wayland': bool(session.get('WAYLAND_DISPLAY')), 'python': sys.version.split()[0]}
701
+ try:
702
+ import dbus
703
+ out['dbusModule'] = True
704
+ except Exception as err:
705
+ out['dbusModule'] = False
706
+ out['dbusWhy'] = str(err)[:120]
707
+ if not session.get('DBUS_SESSION_BUS_ADDRESS'):
708
+ out['bus'] = False
709
+ out['why'] = 'no desktop session'
710
+ return emit(dict(out, ms=took()))
711
+ # A SCREENLESS MACHINE STILL HAS A BUS, AND ASKING IT A QUESTION CREATES A LIE.
712
+ # Measured on 2026-08-31: with the whole desktop torn down, this box still had a
713
+ # systemd user session bus at /run/user/0/bus, and asking that bus for org.a11y.Bus
714
+ # STARTED a brand new accessibility bus with nothing on it. So "there is an
715
+ # accessibility bus" is not the same question as "there is a desktop", and answering
716
+ # the first one would have reported a headless server as an app with no controls -
717
+ # after which every later run would have compared nothing against nothing and agreed.
718
+ # A desktop is a SCREEN. No screen, no walk, and the question is not even asked, which
719
+ # also means nothing is started on a machine that had nothing running.
720
+ if not screen_found:
721
+ out['bus'] = False
722
+ out['why'] = 'there is a user session on this machine but no screen, so there is no desktop on it'
723
+ return emit(dict(out, ms=took()))
724
+ if not out['dbusModule']:
725
+ out['bus'] = False
726
+ out['why'] = 'the python dbus module is not installed here'
727
+ return emit(dict(out, ms=took()))
728
+ try:
729
+ out['a11y'] = a11y_address()
730
+ bus = connect()
731
+ out['bus'] = True
732
+ apps = applications(bus)
733
+ out['apps'] = apps
734
+ desk = extents(bus, 'org.a11y.atspi.Registry', '/org/a11y/atspi/accessible/root')
735
+ out['screen'] = (str(desk['w']) + 'x' + str(desk['h'])) if desk else None
736
+ except Exception as err:
737
+ out['bus'] = False
738
+ out['why'] = str(err)[:200]
739
+ return emit(dict(out, ms=took()))
740
+
741
+ if op == 'launch':
742
+ run_id = req['run']
743
+ folder = req.get('folder') or ('/tmp/staysfixed-linux-' + run_id)
744
+ os.makedirs(folder, exist_ok=True)
745
+ out_file = os.path.join(folder, 'printed.txt')
746
+ err_file = os.path.join(folder, 'complained.txt')
747
+ exit_file = os.path.join(folder, 'exit.txt')
748
+ env = dict(os.environ)
749
+ env.update(session)
750
+ env[MARKER] = run_id
751
+ # The three switches that make a toolkit publish its tree at all. GTK reads the first
752
+ # two; Qt reads the third and the fourth, and without them a Qt window is invisible to
753
+ # the accessibility bus even though the bus is running perfectly.
754
+ env['GTK_MODULES'] = 'gail:atk-bridge'
755
+ env['NO_AT_BRIDGE'] = '0'
756
+ env['QT_ACCESSIBILITY'] = '1'
757
+ env['QT_LINUX_ACCESSIBILITY_ALWAYS_ON'] = '1'
758
+ env.update(req.get('extraEnv') or {})
759
+ args = [str(x) for x in (req.get('args') or [])]
760
+ # Run under a shell that records the exit code AFTER we are gone. The probe exits in a
761
+ # moment; without this the app is reparented to init and its exit code is lost, and
762
+ # "did it fall over" is one of the questions this adapter exists to answer.
763
+ wrapper = '"$0" "$@"; printf %s "$?" > ' + json.dumps(exit_file)
764
+ with open(out_file, 'wb') as so, open(err_file, 'wb') as se:
765
+ p = subprocess.Popen(['/bin/sh', '-c', wrapper, req['exe']] + args,
766
+ cwd=req.get('cwd') or None, env=env,
767
+ stdout=so, stderr=se, stdin=subprocess.DEVNULL,
768
+ start_new_session=True)
769
+ return emit({'ok': True, 'op': op, 'pid': p.pid, 'folder': folder, 'out': out_file,
770
+ 'err': err_file, 'exitFile': exit_file, 'ms': took()})
771
+
772
+ if op == 'windows':
773
+ bus = connect()
774
+ apps = applications(bus)
775
+ want = set(int(x) for x in (req.get('pids') or []))
776
+ rows = []
777
+ import dbus
778
+ for app in apps:
779
+ if want and app['pid'] not in want:
780
+ continue
781
+ try:
782
+ o = bus.get_object(app['bus'], app['path'], introspect=False)
783
+ kids = [(str(a), str(b)) for a, b in dbus.Interface(o, A).GetChildren()]
784
+ except Exception:
785
+ kids = []
786
+ for cd, cp in kids:
787
+ try:
788
+ co = bus.get_object(cd, cp, introspect=False)
789
+ role = str(dbus.Interface(co, A).GetRoleName())
790
+ if role not in WINDOW_ROLES:
791
+ continue
792
+ name = str(dbus.Interface(co, P).Get(A, 'Name'))
793
+ st = state_names(dbus.Interface(co, A).GetState())
794
+ except Exception:
795
+ continue
796
+ rows.append({'bus': cd, 'path': cp, 'role': role, 'name': name, 'app': app['name'],
797
+ 'toolkit': app.get('toolkit', ''), 'pid': app['pid'], 'states': st,
798
+ 'box': extents(bus, cd, cp), 'showing': 'showing' in st})
799
+ return emit({'ok': True, 'op': op, 'windows': rows, 'apps': len(apps), 'ms': took()})
800
+
801
+ if op == 'tree':
802
+ bus = connect()
803
+ limit = int(req.get('limit') or 4000)
804
+ nodes, trouble = read_tree(bus, req['bus'], req['path'], limit)
805
+ return emit({'ok': True, 'op': op, 'nodes': nodes, 'walked': len(nodes),
806
+ 'shapeDisagreed': trouble['shape'], 'unreadable': trouble['unreadable'],
807
+ 'hitLimit': len(nodes) >= limit, 'ms': took()})
808
+
809
+ if op == 'settle':
810
+ bus = connect()
811
+ limit = int(req.get('limit') or 4000)
812
+ tries = int(req.get('tries') or 6)
813
+ gap = float(req.get('gapMs') or 250) / 1000.0
814
+ last = None
815
+ last_key = None
816
+ agreed = False
817
+ reads = 0
818
+ while reads < tries:
819
+ reads += 1
820
+ nodes, trouble = read_tree(bus, req['bus'], req['path'], limit)
821
+ key = '|'.join(n['role'] + '/' + n.get('name', '') + '/' + ','.join(n['states'])
822
+ for n in nodes)
823
+ if last_key == key and reads > 1:
824
+ agreed = True
825
+ last = (nodes, trouble)
826
+ break
827
+ last_key = key
828
+ last = (nodes, trouble)
829
+ time.sleep(gap)
830
+ nodes, trouble = last
831
+ return emit({'ok': True, 'op': op, 'nodes': nodes, 'walked': len(nodes), 'reads': reads,
832
+ 'agreed': agreed, 'shapeDisagreed': trouble['shape'],
833
+ 'unreadable': trouble['unreadable'], 'hitLimit': len(nodes) >= limit,
834
+ 'ms': took()})
835
+
836
+ if op == 'shot':
837
+ got = grab(req.get('box') or {})
838
+ return emit(dict({'op': op, 'ms': took()}, **got))
839
+
840
+ if op == 'after':
841
+ run_id = req['run']
842
+ wrapper = int(req.get('pid') or -1)
843
+ pids = marked_pids(run_id)
844
+ alive = any(p['pid'] == wrapper for p in pids)
845
+ # The app itself is the wrapper's own child. Everything ELSE carrying the marker is a
846
+ # program the app went and started, which is the thing worth reporting.
847
+ app_pid = next((p['pid'] for p in pids if p['ppid'] == wrapper), None)
848
+ exit_code = None
849
+ try:
850
+ exit_code = open(req['exitFile']).read().strip()
851
+ except Exception:
852
+ pass
853
+ def slurp(name, cap=64000):
854
+ try:
855
+ with open(name, 'rb') as fh:
856
+ raw = fh.read(cap + 1)
857
+ return raw[:cap].decode('utf-8', 'replace'), len(raw) > cap
858
+ except Exception:
859
+ return '', False
860
+ printed, printed_cut = slurp(req.get('out') or '')
861
+ complained, complained_cut = slurp(req.get('err') or '')
862
+ dirs = {}
863
+ for d in (req.get('dirs') or []):
864
+ dirs[d] = snap_dir(d)
865
+ return emit({'ok': True, 'op': op, 'running': alive, 'exit': exit_code,
866
+ 'wrapper': wrapper, 'appPid': app_pid,
867
+ 'procs': pids, 'printed': printed, 'printedCut': printed_cut,
868
+ 'complained': complained, 'complainedCut': complained_cut,
869
+ 'conns': sockets_of([p['pid'] for p in pids]), 'dirs': dirs, 'ms': took()})
870
+
871
+ if op == 'snap':
872
+ dirs = {}
873
+ for d in (req.get('dirs') or []):
874
+ dirs[d] = snap_dir(d)
875
+ return emit({'ok': True, 'op': op, 'dirs': dirs, 'ms': took()})
876
+
877
+ if op == 'stop':
878
+ run_id = req['run']
879
+ pid = int(req['pid'])
880
+ # THE REFUSAL. Somebody's real work is on this desktop.
881
+ if env_of(pid).get(MARKER) != run_id:
882
+ return emit({'ok': False, 'op': op,
883
+ 'error': 'refusing to stop a process this run did not start'})
884
+ exit_code = None
885
+ # ASK THE APP TO CLOSE, NOT THE WRAPPER. The little shell holding the app is what
886
+ # writes the exit code down after the app is gone, and killing the whole group at once
887
+ # takes the wrapper with it - so "did it fall over or close cleanly" came back empty
888
+ # every time, which is a question this adapter exists to answer. Measured and fixed on
889
+ # 2026-08-31. So: close the app, let the wrapper record what happened, and only reach
890
+ # for the group if something refuses to go.
891
+ for row in marked_pids(run_id):
892
+ if row['pid'] == pid:
893
+ continue
894
+ try:
895
+ os.kill(row['pid'], signal.SIGTERM)
896
+ except Exception:
897
+ pass
898
+ for _ in range(40):
899
+ if not os.path.exists('/proc/' + str(pid)):
900
+ break
901
+ time.sleep(0.1)
902
+ forced = False
903
+ if os.path.exists('/proc/' + str(pid)):
904
+ forced = True
905
+ try:
906
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
907
+ except Exception:
908
+ pass
909
+ try:
910
+ exit_code = open(req['exitFile']).read().strip()
911
+ except Exception:
912
+ pass
913
+ # AND TAKE THE THREE FILES BACK OFF THAT MACHINE. They exist only so the exit code and
914
+ # the output survive the probe exiting - see the note on the launch operation - and
915
+ # once they have been read there is no reason for them to outlive the run. Nothing this
916
+ # tool does should still be on somebody's disk tomorrow.
917
+ folder = req.get('folder')
918
+ removed = False
919
+ if folder and str(folder).startswith('/tmp/staysfixed-linux-'):
920
+ try:
921
+ import shutil
922
+ shutil.rmtree(folder)
923
+ removed = True
924
+ except Exception:
925
+ pass
926
+ return emit({'ok': True, 'op': op, 'forced': forced, 'exit': exit_code,
927
+ 'tidied': removed, 'ms': took()})
928
+
929
+ emit({'ok': False, 'op': op, 'error': 'nothing here knows how to do ' + str(op)})
930
+
931
+ try:
932
+ main()
933
+ except Exception as err:
934
+ import traceback
935
+ emit({'ok': False, 'error': str(err)[:300], 'trace': traceback.format_exc()[-600:]})
936
+ `;
937
+ }
938
+
939
+ // ---------------------------------------------------------------------------
940
+ // Talking to it
941
+ // ---------------------------------------------------------------------------
942
+
943
+ /**
944
+ * The shell command that runs the probe once with one request.
945
+ *
946
+ * Both the program and the request travel as base64, which is the whole point: they pass
947
+ * through a POSIX shell, an ssh session and Node's argument handling, and base64's alphabet
948
+ * has no quote, dollar, backslash or newline in it for any of those three to get wrong. The
949
+ * Windows probe travels the same way for the same reason.
950
+ *
951
+ * @param {string} python Which python to use.
952
+ * @param {string} script The probe.
953
+ * @param {Record<string, unknown>} request
954
+ * @returns {string}
955
+ */
956
+ export function probeCommand(python, script, request) {
957
+ const program = Buffer.from(script, 'utf8').toString('base64');
958
+ const payload = Buffer.from(JSON.stringify(request), 'utf8').toString('base64');
959
+ return `printf %s '${program}' | base64 -d | ${python} - '${payload}'`;
960
+ }
961
+
962
+ /**
963
+ * @typedef {object} ProbeReply
964
+ * @property {Record<string, any>|null} reply What the probe said, when it said anything.
965
+ * @property {string[]} noise Everything else on the stream, kept verbatim.
966
+ */
967
+
968
+ /**
969
+ * Split what came back into the reply and the noise.
970
+ *
971
+ * A line either carries the sentinel and is the answer, or it does not and is noise. There is
972
+ * no third case and no guessing. A run where the far side printed a GTK warning and nothing
973
+ * else must come back as "it said nothing", never as an answer assembled out of a warning.
974
+ *
975
+ * @param {string} stdout
976
+ * @param {string} [stderr]
977
+ * @returns {ProbeReply}
978
+ */
979
+ export function readProbeReply(stdout, stderr = '') {
980
+ /** @type {Record<string, any>|null} */
981
+ let reply = null;
982
+ /** @type {string[]} */
983
+ const noise = [];
984
+ for (const line of `${stdout}\n${stderr}`.split('\n')) {
985
+ const clean = line.trim();
986
+ if (clean === '') continue;
987
+ if (clean.startsWith(PROBE_SENTINEL)) {
988
+ try {
989
+ reply = JSON.parse(clean.slice(PROBE_SENTINEL.length));
990
+ } catch {
991
+ noise.push(`unreadable reply: ${clean.slice(0, 200)}`);
992
+ }
993
+ } else {
994
+ noise.push(clean);
995
+ }
996
+ }
997
+ return { reply, noise };
998
+ }
999
+
1000
+ /**
1001
+ * One request to the Linux side, over the connection `remote.js` already holds.
1002
+ *
1003
+ * Never returns something that could be mistaken for an answer. A probe that printed nothing,
1004
+ * or printed only a warning, throws — and the adapter turns that into a hole with the reason
1005
+ * attached, which is the only honest thing to do with a screen nobody managed to read.
1006
+ *
1007
+ * @param {import('../remote.js').RemoteRunner} runner
1008
+ * @param {string} op
1009
+ * @param {Record<string, unknown>} [payload]
1010
+ * @param {{timeoutMs?: number, python?: string}} [opts]
1011
+ * @returns {Promise<Record<string, any>>}
1012
+ */
1013
+ export async function askLinux(runner, op, payload = {}, opts = {}) {
1014
+ const command = probeCommand(opts.python ?? 'python3', linuxProbeScript(), { ...payload, op });
1015
+ const result = await runner.shell(command, { timeoutMs: opts.timeoutMs ?? 120_000 });
1016
+ const { reply, noise } = readProbeReply(result.stdout, result.stderr);
1017
+ if (!reply) {
1018
+ throw new RemoteLinkLost(
1019
+ runner.host,
1020
+ result.killed
1021
+ ? `the "${op}" read of that desktop was still running when time ran out`
1022
+ : `the "${op}" read of that desktop said nothing this could use`,
1023
+ noise,
1024
+ );
1025
+ }
1026
+ if (noise.length > 0) reply.noise = noise;
1027
+ return reply;
1028
+ }