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.
- package/CHANGELOG.md +102 -0
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/how-v2-works.md +33 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/guard/api.js +115 -36
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/browsers.js +41 -2
- package/src/v2/check.js +133 -13
- package/src/v2/cli.js +2 -0
- package/src/v2/coverage.js +1 -1
- package/src/v2/detect.js +5 -2
- package/src/v2/doctor.js +164 -20
- package/src/v2/init.js +21 -3
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +8 -15
- package/src/v2/types.js +1 -1
- package/src/v2/watch/events.js +6 -0
|
@@ -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
|
+
}
|