@remcp/runtime 0.2.153 → 0.2.155
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 +5 -0
- package/package.json +1 -1
- package/src/extended/browser-actions.mjs +311 -0
- package/src/extended/browser-endpoint.mjs +469 -0
- package/src/extended/browser-read.mjs +288 -0
- package/src/extended/browser-session.mjs +413 -0
- package/src/extended/browser.mjs +4 -1440
- package/src/extended/desktop-computer-actions.mjs +346 -0
- package/src/extended/desktop-computer-snapshot.mjs +245 -0
- package/src/extended/desktop-core.mjs +449 -0
- package/src/extended/desktop-linux-displays.mjs +366 -0
- package/src/extended/desktop-linux-input.mjs +485 -0
- package/src/extended/desktop-linux-screen.mjs +105 -0
- package/src/extended/desktop-linux-ui.mjs +407 -0
- package/src/extended/desktop-linux-window-actions.mjs +237 -0
- package/src/extended/desktop-linux-windows.mjs +321 -0
- package/src/extended/desktop-linux.mjs +6 -1791
- package/src/extended/desktop-ui-actions.mjs +402 -0
- package/src/extended/desktop-vision.mjs +209 -0
- package/src/extended/desktop.mjs +6 -1583
- package/src/tools/files-archive-actions.mjs +166 -0
- package/src/tools/files-archive-safety.mjs +395 -0
- package/src/tools/files-bulk-operations.mjs +415 -0
- package/src/tools/files-read-traversal.mjs +347 -0
- package/src/tools/files-screenshots.mjs +167 -0
- package/src/tools/files-secure-paths.mjs +408 -0
- package/src/tools/files-write-edit.mjs +336 -0
- package/src/tools/files.mjs +8 -2111
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
import { image, multi, text } from '../util.mjs';
|
|
6
|
+
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
7
|
+
import { hasWaylandRemoteDesktopGrant, portalPointerButton, portalPointerMotion, portalPointerMotionAbsolute, portalScroll, portalShortcut, portalTypeText, waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
|
|
8
|
+
import {
|
|
9
|
+
clamp,
|
|
10
|
+
commandExists,
|
|
11
|
+
jsonResult,
|
|
12
|
+
optionalString,
|
|
13
|
+
readPrivateTempFile,
|
|
14
|
+
removeTemp,
|
|
15
|
+
requireEnum,
|
|
16
|
+
runFile,
|
|
17
|
+
spawnDetached,
|
|
18
|
+
spawnDetachedWithInput,
|
|
19
|
+
tempDir,
|
|
20
|
+
unavailable,
|
|
21
|
+
} from './common.mjs';
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
import { listWindows, targetAccessibilityFocus, windowMatch } from './desktop-linux-windows.mjs';
|
|
25
|
+
import { nativeWaylandWindowAction } from './desktop-linux-window-actions.mjs';
|
|
26
|
+
import { inputBackend, pointer, portalPermissionHint, scroll } from './desktop-linux-input.mjs';
|
|
27
|
+
|
|
28
|
+
export async function windowAction(args) {
|
|
29
|
+
const action = requireEnum(args.action, 'action', ['focus','minimize','maximize','restore','move','resize','move_resize','close']);
|
|
30
|
+
const row = await windowMatch(args);
|
|
31
|
+
const id = row.wm_id || row.id;
|
|
32
|
+
const nativeWayland = isWaylandSession() && !row.wm_id && !/^0x/i.test(String(id || ''));
|
|
33
|
+
if (action === 'focus') {
|
|
34
|
+
if (isWaylandSession()) {
|
|
35
|
+
const backend = inputBackend(args);
|
|
36
|
+
if (row.ui_id && backend !== 'portal') {
|
|
37
|
+
try {
|
|
38
|
+
await uiAction({ action:'focus', id:row.ui_id });
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
40
|
+
const focusState = await targetAccessibilityFocus(row);
|
|
41
|
+
if (focusState.exact) return text('Window action focus completed via verified AT-SPI focus.');
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
// XWayland windows have a real X11 wm_id even inside a Wayland session. Prefer
|
|
45
|
+
// direct activation for those windows, verify the result through accessibility,
|
|
46
|
+
// and only fall back to compositor-level cycling if direct activation did not take.
|
|
47
|
+
// This avoids dozens of Alt+Esc/AT-SPI round trips on busy desktops.
|
|
48
|
+
if (row.wm_id && backend !== 'portal' && commandExists('wmctrl')) {
|
|
49
|
+
const activated = await runFile('wmctrl', ['-ia', row.wm_id], { label:'XWayland window focus', allowFailure:true, timeout:2000 });
|
|
50
|
+
if (activated.code === 0) {
|
|
51
|
+
if (commandExists('xdotool')) {
|
|
52
|
+
await runFile('xdotool', ['windowactivate','--sync',row.wm_id], { label:'XWayland window focus sync', allowFailure:true, timeout:3000 });
|
|
53
|
+
}
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
55
|
+
const focusState = await targetAccessibilityFocus(row);
|
|
56
|
+
if (focusState.exact) return text('Window action focus completed via verified XWayland activation.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (backend === 'x11') unavailable('Wayland window focus', row.wm_id
|
|
60
|
+
? 'direct XWayland activation did not produce verifiable focus'
|
|
61
|
+
: 'the target is native Wayland and has no X11 window id; use backend=portal');
|
|
62
|
+
if (!waylandPortalCandidate()) unavailable('Wayland window focus', 'no XDG RemoteDesktop portal is available');
|
|
63
|
+
if (backend === 'auto' && !hasWaylandRemoteDesktopGrant()) portalPermissionHint('Wayland window focus');
|
|
64
|
+
const portalOptions = { timeoutMs: backend === 'portal' ? 120_000 : 2500 };
|
|
65
|
+
let focusState = await targetAccessibilityFocus(row);
|
|
66
|
+
if (focusState.exact) return text('Window action focus already matched the target window.');
|
|
67
|
+
|
|
68
|
+
const rows = JSON.parse((await listWindows()).content?.[0]?.text || '[]');
|
|
69
|
+
const windowCycles = Math.min(60, Math.max(8, rows.length + 4));
|
|
70
|
+
for (let index = 0; index < windowCycles; index += 1) {
|
|
71
|
+
await portalShortcut('ALT+ESC', portalOptions);
|
|
72
|
+
await new Promise(resolve => setTimeout(resolve, 140));
|
|
73
|
+
focusState = await targetAccessibilityFocus(row);
|
|
74
|
+
if (focusState.exact) return text('Window action focus completed via verified Wayland Alt+Esc.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const appCycles = Math.min(12, Math.max(3, new Set(rows.map(item => Number(item.pid) || String(item.app || ''))).size + 1));
|
|
78
|
+
for (let index = 0; index < appCycles; index += 1) {
|
|
79
|
+
await portalShortcut('ALT+TAB', portalOptions);
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 160));
|
|
81
|
+
focusState = await targetAccessibilityFocus(row);
|
|
82
|
+
if (focusState.exact) return text('Window action focus completed via verified Wayland Alt+Tab fallback.');
|
|
83
|
+
if (focusState.active) {
|
|
84
|
+
const sameAppWindows = rows.filter(item => Number(item.pid) === Number(row.pid)).length;
|
|
85
|
+
for (let windowIndex = 0; windowIndex < Math.min(8, Math.max(2, sameAppWindows + 1)); windowIndex += 1) {
|
|
86
|
+
await portalShortcut('ALT+`', portalOptions);
|
|
87
|
+
await new Promise(resolve => setTimeout(resolve, 120));
|
|
88
|
+
focusState = await targetAccessibilityFocus(row);
|
|
89
|
+
if (focusState.exact) return text('Window action focus completed via verified Wayland same-app fallback.');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`Could not verify focus for ${row.title || row.app || row.id} after bounded Wayland window cycling`);
|
|
94
|
+
}
|
|
95
|
+
if (!commandExists('wmctrl')) unavailable('Window focus', 'install wmctrl');
|
|
96
|
+
await runFile('wmctrl', ['-ia', id], { label: 'window focus' });
|
|
97
|
+
if (commandExists('xdotool')) {
|
|
98
|
+
const activated = await runFile('xdotool', ['windowactivate','--sync',id], { label:'window focus sync', allowFailure:true, timeout:3000 });
|
|
99
|
+
if (activated.code !== 0) await new Promise(resolve => setTimeout(resolve, 80));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (nativeWayland) return nativeWaylandWindowAction(row, action, args);
|
|
103
|
+
else if (!commandExists('wmctrl')) unavailable('Window action', 'install wmctrl');
|
|
104
|
+
else if (action === 'close') await runFile('wmctrl', ['-ic', id], { label: 'window close' });
|
|
105
|
+
else if (action === 'minimize') await runFile('wmctrl', ['-ir', id, '-b', 'add,hidden'], { label: 'window minimize' });
|
|
106
|
+
else if (action === 'maximize') await runFile('wmctrl', ['-ir', id, '-b', 'add,maximized_vert,maximized_horz'], { label: 'window maximize' });
|
|
107
|
+
else if (action === 'restore') await runFile('wmctrl', ['-ir', id, '-b', 'remove,hidden,maximized_vert,maximized_horz'], { label: 'window restore' });
|
|
108
|
+
else {
|
|
109
|
+
const x = Number(args.x), y = Number(args.y), width = Number(args.width), height = Number(args.height);
|
|
110
|
+
const geometry = `0,${Number.isFinite(x) ? Math.trunc(x) : -1},${Number.isFinite(y) ? Math.trunc(y) : -1},${Number.isFinite(width) ? Math.trunc(width) : -1},${Number.isFinite(height) ? Math.trunc(height) : -1}`;
|
|
111
|
+
await runFile('wmctrl', ['-ir', id, '-e', geometry], { label: 'window geometry' });
|
|
112
|
+
}
|
|
113
|
+
return text(`Window action ${action} completed.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function pyAtSpiPrelude() {
|
|
117
|
+
return `import json,sys,math
|
|
118
|
+
try:
|
|
119
|
+
import pyatspi
|
|
120
|
+
except Exception:
|
|
121
|
+
try:
|
|
122
|
+
import gi
|
|
123
|
+
gi.require_version("Atspi","2.0")
|
|
124
|
+
from gi.repository import Atspi
|
|
125
|
+
class _Component:
|
|
126
|
+
def __init__(self,a): self.a=a
|
|
127
|
+
def getExtents(self,coords): return self.a.get_extents(coords)
|
|
128
|
+
def grabFocus(self): return self.a.grab_focus()
|
|
129
|
+
class _Value:
|
|
130
|
+
def __init__(self,a): self.a=a
|
|
131
|
+
@property
|
|
132
|
+
def currentValue(self): return self.a.get_current_value()
|
|
133
|
+
@currentValue.setter
|
|
134
|
+
def currentValue(self,v): self.a.set_current_value(float(v))
|
|
135
|
+
class _Action:
|
|
136
|
+
def __init__(self,a): self.a=a
|
|
137
|
+
@property
|
|
138
|
+
def nActions(self): return self.a.get_n_actions()
|
|
139
|
+
def getName(self,i): return self.a.get_action_name(i)
|
|
140
|
+
def doAction(self,i): return self.a.do_action(i)
|
|
141
|
+
class _Editable:
|
|
142
|
+
def __init__(self,a): self.a=a
|
|
143
|
+
def setTextContents(self,v): return self.a.set_text_contents(v)
|
|
144
|
+
class _Accessible:
|
|
145
|
+
def __init__(self,a): self.a=a
|
|
146
|
+
@property
|
|
147
|
+
def name(self): return self.a.get_name()
|
|
148
|
+
@property
|
|
149
|
+
def childCount(self): return self.a.get_child_count()
|
|
150
|
+
def getState(self): return self.a.get_state_set()
|
|
151
|
+
def getRoleName(self): return self.a.get_role_name()
|
|
152
|
+
def getChildAtIndex(self,i): return _Accessible(self.a.get_child_at_index(i))
|
|
153
|
+
def queryComponent(self): return _Component(self.a)
|
|
154
|
+
def queryValue(self):
|
|
155
|
+
iface=self.a.get_value_iface()
|
|
156
|
+
if iface is None: raise Exception("Value interface unavailable")
|
|
157
|
+
return _Value(self.a)
|
|
158
|
+
def queryAction(self): return _Action(self.a)
|
|
159
|
+
def queryEditableText(self): return _Editable(self.a)
|
|
160
|
+
def getProcessId(self):
|
|
161
|
+
try: return int(self.a.get_process_id())
|
|
162
|
+
except: return 0
|
|
163
|
+
def __iter__(self):
|
|
164
|
+
for i in range(self.childCount): yield self.getChildAtIndex(i)
|
|
165
|
+
class _Registry:
|
|
166
|
+
@staticmethod
|
|
167
|
+
def getDesktop(i): return _Accessible(Atspi.get_desktop(i))
|
|
168
|
+
class _Compat:
|
|
169
|
+
Registry=_Registry
|
|
170
|
+
STATE_ACTIVE=Atspi.StateType.ACTIVE
|
|
171
|
+
STATE_SHOWING=Atspi.StateType.SHOWING
|
|
172
|
+
STATE_FOCUSED=Atspi.StateType.FOCUSED
|
|
173
|
+
DESKTOP_COORDS=Atspi.CoordType.SCREEN
|
|
174
|
+
pyatspi=_Compat()
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
print(json.dumps({"error":"AT-SPI unavailable: %s"%exc}));sys.exit(3)
|
|
177
|
+
desktop=pyatspi.Registry.getDesktop(0)
|
|
178
|
+
def _proc(e):
|
|
179
|
+
for attr in ("getProcessId","get_process_id"):
|
|
180
|
+
try:
|
|
181
|
+
return int(getattr(e,attr)())
|
|
182
|
+
except: pass
|
|
183
|
+
try:
|
|
184
|
+
app=e.getApplication()
|
|
185
|
+
for attr in ("getProcessId","get_process_id"):
|
|
186
|
+
try: return int(getattr(app,attr)())
|
|
187
|
+
except: pass
|
|
188
|
+
except: pass
|
|
189
|
+
return 0
|
|
190
|
+
q=[]
|
|
191
|
+
for app in desktop:
|
|
192
|
+
try:
|
|
193
|
+
app_name=app.name or ""
|
|
194
|
+
pid=_proc(app)
|
|
195
|
+
active=False
|
|
196
|
+
try:
|
|
197
|
+
active=bool(app.getState().contains(pyatspi.STATE_ACTIVE))
|
|
198
|
+
if not active:
|
|
199
|
+
for child_i in range(min(app.childCount,64)):
|
|
200
|
+
try:
|
|
201
|
+
child=app.getChildAtIndex(child_i)
|
|
202
|
+
if child.getState().contains(pyatspi.STATE_ACTIVE):
|
|
203
|
+
active=True;break
|
|
204
|
+
except: pass
|
|
205
|
+
except: pass
|
|
206
|
+
if app.childCount>0 or pid>0:q.append((app,app_name,pid,active))
|
|
207
|
+
except:pass
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function uiSnapshot(args = {}) {
|
|
212
|
+
if (!commandExists('python3')) unavailable('Linux accessibility', 'python3 with python3-pyatspi is required');
|
|
213
|
+
const maxNodes = clamp(args.max_nodes, 500, 1, 5000);
|
|
214
|
+
const maxDepth = clamp(args.max_depth, 8, 1, 32);
|
|
215
|
+
const wantedApp = JSON.stringify(optionalString(args.app) || '');
|
|
216
|
+
const wantedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : 0;
|
|
217
|
+
const wantedWindow = JSON.stringify(optionalString(args.window_title || args.windowTitle) || '');
|
|
218
|
+
const activeOnly = args.active_only === true;
|
|
219
|
+
const script = pyAtSpiPrelude() + `
|
|
220
|
+
want_app=${wantedApp};want_pid=${wantedPid};want_window=${wantedWindow};want_active=${activeOnly ? 'True' : 'False'}
|
|
221
|
+
roots=[]
|
|
222
|
+
for root,app_name,pid,active in q:
|
|
223
|
+
if want_pid and pid!=want_pid: continue
|
|
224
|
+
if want_app and want_app.lower() not in app_name.lower(): continue
|
|
225
|
+
if want_active and not active: continue
|
|
226
|
+
roots.append((root,app_name,pid,active))
|
|
227
|
+
out=[];queue=[];seen=0
|
|
228
|
+
for root_index,(e,app_name,pid,active) in enumerate(roots):
|
|
229
|
+
if want_window:
|
|
230
|
+
top_match=False
|
|
231
|
+
try:
|
|
232
|
+
for child in e:
|
|
233
|
+
try:
|
|
234
|
+
child_name=child.name or ""
|
|
235
|
+
child_role=child.getRoleName().lower()
|
|
236
|
+
if child_role in ("frame","dialog","window") and want_window.lower() in child_name.lower():
|
|
237
|
+
top_match=True;break
|
|
238
|
+
except: pass
|
|
239
|
+
except: pass
|
|
240
|
+
if not top_match: continue
|
|
241
|
+
root_path="r" if pid else "a%d"%root_index
|
|
242
|
+
queue.append((e,0,app_name,pid,active,"",root_path))
|
|
243
|
+
while queue and len(out)<${maxNodes}:
|
|
244
|
+
e,d,app_name,pid,active,window_name,node_path=queue.pop(0);current_index=seen;seen+=1
|
|
245
|
+
try: role=e.getRoleName()
|
|
246
|
+
except: role=""
|
|
247
|
+
try: name=e.name or ""
|
|
248
|
+
except: name=""
|
|
249
|
+
if role.lower() in ("frame","dialog","window") and name: window_name=name
|
|
250
|
+
try:
|
|
251
|
+
ext=e.queryComponent().getExtents(pyatspi.DESKTOP_COORDS);box={"x":ext.x,"y":ext.y,"width":ext.width,"height":ext.height}
|
|
252
|
+
except: box={"x":0,"y":0,"width":0,"height":0}
|
|
253
|
+
try:
|
|
254
|
+
value=e.queryValue().currentValue
|
|
255
|
+
if isinstance(value,float) and not math.isfinite(value): value=""
|
|
256
|
+
except: value=""
|
|
257
|
+
focused=False;node_active=active
|
|
258
|
+
try:
|
|
259
|
+
states=e.getState();focused=bool(states.contains(pyatspi.STATE_FOCUSED));node_active=bool(node_active or states.contains(pyatspi.STATE_ACTIVE))
|
|
260
|
+
except: pass
|
|
261
|
+
if not want_window or want_window.lower() in window_name.lower():
|
|
262
|
+
out.append({"id":"linux:%d:%s"%(pid,node_path),"index":current_index,"depth":d,"pid":pid,"app":app_name,"active":node_active,"focused":focused,"window":window_name,"role":role,"name":name,"value":value,**box})
|
|
263
|
+
if d<${maxDepth}:
|
|
264
|
+
try:
|
|
265
|
+
for i in range(e.childCount):queue.append((e.getChildAtIndex(i),d+1,app_name,pid,active,window_name,node_path+"."+str(i)))
|
|
266
|
+
except:pass
|
|
267
|
+
print(json.dumps(out))`;
|
|
268
|
+
const result = await runFile('python3', ['-c', script], { label: 'AT-SPI snapshot', timeout: 30_000, allowFailure: true });
|
|
269
|
+
let parsed = null;
|
|
270
|
+
try { parsed = JSON.parse(result.stdout || ''); } catch {}
|
|
271
|
+
if (Array.isArray(parsed)) return jsonResult({ platform: 'linux', count: parsed.length, nodes: parsed });
|
|
272
|
+
if (parsed?.error) unavailable('Linux accessibility', parsed.error);
|
|
273
|
+
if (result.code !== 0) unavailable('Linux accessibility', (result.stderr || result.stdout).trim());
|
|
274
|
+
return jsonResult({ platform: 'linux', count: 0, nodes: [] });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function uiAction(args = {}) {
|
|
278
|
+
const action = requireEnum(args.action, 'action', ['click','invoke','focus','set_value','select','toggle','expand','collapse','scroll_into_view','set_range_value','add_to_selection','remove_from_selection']);
|
|
279
|
+
if (!commandExists('python3')) unavailable('Linux accessibility action', 'python3 with python3-pyatspi is required');
|
|
280
|
+
const id = optionalString(args.id);
|
|
281
|
+
const idParts = id?.split(':') || [];
|
|
282
|
+
const legacyIndex = idParts.length === 2 && idParts[0] === 'linux' && /^\d+$/.test(idParts[1]) ? Number(idParts[1]) : -1;
|
|
283
|
+
const stablePid = idParts.length >= 3 && idParts[0] === 'linux' && /^\d+$/.test(idParts[1]) ? Number(idParts[1]) : 0;
|
|
284
|
+
const stablePath = idParts.length >= 3 && idParts[0] === 'linux' ? idParts.slice(2).join(':') : '';
|
|
285
|
+
const wantedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : stablePid;
|
|
286
|
+
const wantedApp = JSON.stringify(optionalString(args.app) || '');
|
|
287
|
+
const wantedWindow = JSON.stringify(optionalString(args.window_title || args.windowTitle) || '');
|
|
288
|
+
const wantedName = JSON.stringify(optionalString(args.name) || '');
|
|
289
|
+
const wantedRole = JSON.stringify(optionalString(args.role) || '');
|
|
290
|
+
const value = JSON.stringify(String(args.value ?? ''));
|
|
291
|
+
const script = pyAtSpiPrelude() + `
|
|
292
|
+
want_index=${legacyIndex};want_pid=${wantedPid};want_path=${JSON.stringify(stablePath)}
|
|
293
|
+
want_app=${wantedApp};want_window=${wantedWindow};want_name=${wantedName};want_role=${wantedRole};action=${JSON.stringify(action)};value=${value}
|
|
294
|
+
queue=[];seen=0;target=None;target_meta={}
|
|
295
|
+
for root_index,(root,app_name,pid,active) in enumerate(q):
|
|
296
|
+
if want_pid and pid!=want_pid: continue
|
|
297
|
+
if want_app and want_app.lower() not in app_name.lower(): continue
|
|
298
|
+
root_path="r" if pid else "a%d"%root_index
|
|
299
|
+
queue.append((root,app_name,pid,"",root_path))
|
|
300
|
+
while queue:
|
|
301
|
+
e,app_name,pid,window_name,node_path=queue.pop(0);current_index=seen;seen+=1
|
|
302
|
+
try: role=e.getRoleName();name=e.name or ""
|
|
303
|
+
except: role="";name=""
|
|
304
|
+
if role.lower() in ("frame","dialog","window") and name: window_name=name
|
|
305
|
+
id_match=(not want_path or (pid==want_pid and node_path==want_path)) and (want_index<0 or current_index==want_index)
|
|
306
|
+
selector_match=(not want_name or want_name.lower() in name.lower()) and (not want_role or want_role.lower() in role.lower()) and (not want_window or want_window.lower() in window_name.lower())
|
|
307
|
+
if id_match and selector_match:
|
|
308
|
+
target=e;target_meta={"pid":pid,"app":app_name,"window":window_name,"path":node_path};break
|
|
309
|
+
try:
|
|
310
|
+
for i in range(e.childCount):queue.append((e.getChildAtIndex(i),app_name,pid,window_name,node_path+"."+str(i)))
|
|
311
|
+
except:pass
|
|
312
|
+
if target is None:raise Exception("UI element not found")
|
|
313
|
+
pointer_fallback=None
|
|
314
|
+
if action in ("click","invoke"):
|
|
315
|
+
try:
|
|
316
|
+
applied=target.queryAction().doAction(0)
|
|
317
|
+
if applied is False: raise Exception("accessibility action returned false")
|
|
318
|
+
except Exception as action_error:
|
|
319
|
+
try:
|
|
320
|
+
extents=target.queryComponent().getExtents(pyatspi.DESKTOP_COORDS)
|
|
321
|
+
def _rect_value(rect,name,index):
|
|
322
|
+
try: return int(getattr(rect,name))
|
|
323
|
+
except:
|
|
324
|
+
try: return int(rect[index])
|
|
325
|
+
except: return 0
|
|
326
|
+
px=_rect_value(extents,"x",0);py=_rect_value(extents,"y",1)
|
|
327
|
+
pw=_rect_value(extents,"width",2);ph=_rect_value(extents,"height",3)
|
|
328
|
+
if pw<=0 or ph<=0: raise Exception("target has no usable screen bounds")
|
|
329
|
+
pointer_fallback={"x":px+pw//2,"y":py+ph//2,"width":pw,"height":ph,"reason":str(action_error)}
|
|
330
|
+
except Exception as bounds_error:
|
|
331
|
+
raise Exception("accessibility action failed: %s; coordinate fallback unavailable: %s"%(action_error,bounds_error))
|
|
332
|
+
elif action in ("select","toggle"):
|
|
333
|
+
applied=target.queryAction().doAction(0)
|
|
334
|
+
if applied is False: raise Exception(action+" accessibility action returned false")
|
|
335
|
+
elif action=="focus":
|
|
336
|
+
already_focused=False
|
|
337
|
+
try: already_focused=bool(target.getState().contains(pyatspi.STATE_FOCUSED))
|
|
338
|
+
except: pass
|
|
339
|
+
if not already_focused:
|
|
340
|
+
try:
|
|
341
|
+
if target.queryComponent().grabFocus() is False: raise Exception("focus accessibility action returned false")
|
|
342
|
+
except Exception as focus_error:
|
|
343
|
+
try:
|
|
344
|
+
if not target.getState().contains(pyatspi.STATE_FOCUSED): raise focus_error
|
|
345
|
+
except Exception:
|
|
346
|
+
raise focus_error
|
|
347
|
+
elif action=="set_value":
|
|
348
|
+
try:
|
|
349
|
+
applied=target.queryEditableText().setTextContents(value)
|
|
350
|
+
if applied is False: raise Exception("editable text write returned false")
|
|
351
|
+
except Exception as editable_error:
|
|
352
|
+
try: target.queryValue().currentValue=value
|
|
353
|
+
except Exception as value_error: raise Exception("set_value failed: editable=%s; value=%s"%(editable_error,value_error))
|
|
354
|
+
elif action=="set_range_value":
|
|
355
|
+
target.queryValue().currentValue=float(value)
|
|
356
|
+
elif action in ("expand","collapse"):
|
|
357
|
+
actions=target.queryAction();chosen=-1
|
|
358
|
+
for i in range(actions.nActions):
|
|
359
|
+
try:
|
|
360
|
+
action_name=(actions.getName(i) or "").lower()
|
|
361
|
+
if action in action_name: chosen=i;break
|
|
362
|
+
except: pass
|
|
363
|
+
if chosen<0: raise Exception(action+" accessibility action unavailable")
|
|
364
|
+
actions.doAction(chosen)
|
|
365
|
+
elif action=="scroll_into_view":
|
|
366
|
+
component=target.queryComponent()
|
|
367
|
+
try: component.scrollTo(pyatspi.SCROLL_ANYWHERE)
|
|
368
|
+
except:
|
|
369
|
+
if not component.grabFocus(): raise Exception("scroll-to-view unavailable")
|
|
370
|
+
elif action in ("add_to_selection","remove_from_selection"):
|
|
371
|
+
parent=target.parent
|
|
372
|
+
if parent is None: raise Exception("selection parent unavailable")
|
|
373
|
+
selection=parent.querySelection();idx=target.getIndexInParent()
|
|
374
|
+
if action=="add_to_selection":
|
|
375
|
+
if not selection.selectChild(idx): raise Exception("could not add item to selection")
|
|
376
|
+
else:
|
|
377
|
+
if not selection.deselectChild(idx): raise Exception("could not remove item from selection")
|
|
378
|
+
out={"action":action,"name":target.name or "","role":target.getRoleName(),**target_meta}
|
|
379
|
+
if pointer_fallback is not None: out["pointer_fallback"]=pointer_fallback
|
|
380
|
+
print(json.dumps(out))`;
|
|
381
|
+
const result = await runFile('python3', ['-c', script], { label: 'AT-SPI action', timeout: 30_000, allowFailure: true });
|
|
382
|
+
let payload = null;
|
|
383
|
+
try { payload = JSON.parse(result.stdout || ''); } catch {}
|
|
384
|
+
if (payload?.pointer_fallback) {
|
|
385
|
+
const fallback = payload.pointer_fallback;
|
|
386
|
+
const x = Number(fallback.x), y = Number(fallback.y);
|
|
387
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) unavailable('Linux accessibility action', 'coordinate fallback returned invalid screen bounds');
|
|
388
|
+
await pointer({ action:'click', x, y, ...(args.backend ? { backend:args.backend } : {}) });
|
|
389
|
+
return jsonResult({
|
|
390
|
+
action,
|
|
391
|
+
id:id || null,
|
|
392
|
+
name:payload.name || '',
|
|
393
|
+
role:payload.role || '',
|
|
394
|
+
backend:'pointer_fallback',
|
|
395
|
+
x,
|
|
396
|
+
y,
|
|
397
|
+
fallback_reason:String(fallback.reason || 'accessibility action unavailable').slice(0, 500),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (payload && !payload.error) return text(JSON.stringify(payload));
|
|
401
|
+
if (payload?.error) unavailable('Linux accessibility action', payload.error);
|
|
402
|
+
if (result.code !== 0) {
|
|
403
|
+
const detail = String(result.stderr || result.stdout || 'AT-SPI action failed').trim().split(/\r?\n/).filter(Boolean).at(-1) || 'AT-SPI action failed';
|
|
404
|
+
unavailable('Linux accessibility action', detail);
|
|
405
|
+
}
|
|
406
|
+
return text(result.stdout.trim());
|
|
407
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
import { image, multi, text } from '../util.mjs';
|
|
6
|
+
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
7
|
+
import { hasWaylandRemoteDesktopGrant, portalPointerButton, portalPointerMotion, portalPointerMotionAbsolute, portalScroll, portalShortcut, portalTypeText, waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
|
|
8
|
+
import {
|
|
9
|
+
clamp,
|
|
10
|
+
commandExists,
|
|
11
|
+
jsonResult,
|
|
12
|
+
optionalString,
|
|
13
|
+
readPrivateTempFile,
|
|
14
|
+
removeTemp,
|
|
15
|
+
requireEnum,
|
|
16
|
+
runFile,
|
|
17
|
+
spawnDetached,
|
|
18
|
+
spawnDetachedWithInput,
|
|
19
|
+
tempDir,
|
|
20
|
+
unavailable,
|
|
21
|
+
} from './common.mjs';
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
import { explicitInputWindowSelector, targetAccessibilityFocus, windowMatch } from './desktop-linux-windows.mjs';
|
|
25
|
+
import { pyAtSpiPrelude, windowAction } from './desktop-linux-ui.mjs';
|
|
26
|
+
import { dragDrop, inputBackend, portalPermissionHint } from './desktop-linux-input.mjs';
|
|
27
|
+
import { displayInventory } from './desktop-linux-displays.mjs';
|
|
28
|
+
|
|
29
|
+
export async function focusExplicitWaylandInputTarget(args = {}) {
|
|
30
|
+
const selector = explicitInputWindowSelector(args);
|
|
31
|
+
if (!selector) return null;
|
|
32
|
+
const row = await windowMatch(selector);
|
|
33
|
+
await windowAction({ action:'focus', id:row.id, backend:inputBackend(args) });
|
|
34
|
+
return row;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function nativeWaylandGeometryMatches(row, expected = {}, tolerance = 24) {
|
|
38
|
+
for (const key of ['x','y','width','height']) {
|
|
39
|
+
if (expected[key] == null) continue;
|
|
40
|
+
const actual = Number(row?.[key]);
|
|
41
|
+
const wanted = Number(expected[key]);
|
|
42
|
+
if (!Number.isFinite(actual) || !Number.isFinite(wanted) || Math.abs(actual - wanted) > tolerance) return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function verifyNativeWaylandGeometry(row, expected, label) {
|
|
48
|
+
let latest = row;
|
|
49
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
50
|
+
if (attempt > 0) await new Promise(resolve => setTimeout(resolve, 120));
|
|
51
|
+
try {
|
|
52
|
+
latest = await windowMatch({ id:row.id });
|
|
53
|
+
if (nativeWaylandGeometryMatches(latest, expected)) return latest;
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
const actual = ['x','y','width','height']
|
|
57
|
+
.map(key => `${key}=${Number.isFinite(Number(latest?.[key])) ? Number(latest[key]) : 'unknown'}`)
|
|
58
|
+
.join(' ');
|
|
59
|
+
const wanted = Object.entries(expected)
|
|
60
|
+
.map(([key,value]) => `${key}=${value}`)
|
|
61
|
+
.join(' ');
|
|
62
|
+
throw new Error(`Could not verify native Wayland window ${label} for ${row.title || row.app || row.id}: wanted ${wanted}; observed ${actual}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function nativeWaylandAccessibilityAction(row, actionNames) {
|
|
66
|
+
const pid = Number(row?.pid);
|
|
67
|
+
if (!Number.isInteger(pid) || pid <= 0 || !Array.isArray(actionNames) || !actionNames.length) {
|
|
68
|
+
return { applied:false, available:[] };
|
|
69
|
+
}
|
|
70
|
+
const script = pyAtSpiPrelude() + `
|
|
71
|
+
want_pid=${pid};want_window=${JSON.stringify(String(row.title || ''))};wanted_actions=${JSON.stringify(actionNames)}
|
|
72
|
+
target=None
|
|
73
|
+
for root,app_name,pid,active in q:
|
|
74
|
+
if pid!=want_pid: continue
|
|
75
|
+
try:
|
|
76
|
+
for child in root:
|
|
77
|
+
try:
|
|
78
|
+
role=(child.getRoleName() or "").lower();name=child.name or ""
|
|
79
|
+
if role not in ("frame","window","dialog"): continue
|
|
80
|
+
if want_window and want_window.strip().lower()!=name.strip().lower(): continue
|
|
81
|
+
target=child;break
|
|
82
|
+
except: pass
|
|
83
|
+
except: pass
|
|
84
|
+
if target is not None: break
|
|
85
|
+
if target is None:
|
|
86
|
+
print(json.dumps({"applied":False,"available":[],"reason":"target window not found"}))
|
|
87
|
+
else:
|
|
88
|
+
try:
|
|
89
|
+
actions=target.queryAction();available=[];chosen=-1;chosen_name=""
|
|
90
|
+
for i in range(actions.nActions):
|
|
91
|
+
try: action_name=actions.getName(i) or ""
|
|
92
|
+
except: action_name=""
|
|
93
|
+
available.append(action_name)
|
|
94
|
+
if chosen<0 and action_name in wanted_actions:
|
|
95
|
+
chosen=i;chosen_name=action_name
|
|
96
|
+
if chosen<0:
|
|
97
|
+
print(json.dumps({"applied":False,"available":available,"reason":"requested accessibility action unavailable"}))
|
|
98
|
+
else:
|
|
99
|
+
applied=actions.doAction(chosen)
|
|
100
|
+
if applied is False: raise Exception("accessibility window action returned false")
|
|
101
|
+
print(json.dumps({"applied":True,"action":chosen_name,"available":available}))
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
print(json.dumps({"applied":False,"available":[],"reason":str(exc)}))
|
|
104
|
+
`;
|
|
105
|
+
const result = await runFile('python3', ['-c', script], { label:'AT-SPI window action', timeout:2000, allowFailure:true });
|
|
106
|
+
try {
|
|
107
|
+
const payload = JSON.parse(result.stdout || '{}');
|
|
108
|
+
return payload && typeof payload === 'object' ? payload : { applied:false, available:[] };
|
|
109
|
+
} catch {
|
|
110
|
+
return { applied:false, available:[], reason:String(result.stderr || result.stdout || 'AT-SPI window action failed').trim() };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function verifyNativeWaylandWindowClosed(row, timeoutMs = 2500) {
|
|
115
|
+
const deadline = Date.now() + timeoutMs;
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
try { await windowMatch({ id:row.id }); }
|
|
118
|
+
catch { return; }
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`Could not verify native Wayland window close for ${row.title || row.app || row.id}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function focusNativeWaylandForAction(row, backend) {
|
|
125
|
+
await windowAction({ action:'focus', id:row.id, backend });
|
|
126
|
+
const focusState = await targetAccessibilityFocus(row);
|
|
127
|
+
if (!focusState.exact) throw new Error(`Could not verify native Wayland focus for ${row.title || row.app || row.id}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function nativeWaylandWindowAction(row, action, args) {
|
|
131
|
+
const backend = inputBackend(args);
|
|
132
|
+
if (backend === 'x11') unavailable('Native Wayland window action', 'use backend=portal for compositor-managed windows');
|
|
133
|
+
if (!waylandPortalCandidate()) unavailable('Native Wayland window action', 'no XDG RemoteDesktop portal is available');
|
|
134
|
+
if (backend === 'auto' && !hasWaylandRemoteDesktopGrant()) portalPermissionHint('Native Wayland window action');
|
|
135
|
+
|
|
136
|
+
const portalBackend = backend === 'portal' ? 'portal' : 'auto';
|
|
137
|
+
const timeoutMs = backend === 'portal' ? 120_000 : 2500;
|
|
138
|
+
if (action === 'close') {
|
|
139
|
+
const semantic = await nativeWaylandAccessibilityAction(row, ['window.close']);
|
|
140
|
+
if (semantic.applied) {
|
|
141
|
+
await verifyNativeWaylandWindowClosed(row);
|
|
142
|
+
return jsonResult({ action, backend:'atspi-window-action', id:row.id, accessibility_action:semantic.action });
|
|
143
|
+
}
|
|
144
|
+
// Some GTK apps disconnect from AT-SPI while handling window.close, before the action
|
|
145
|
+
// call can return a success payload. Trust the observed target state, not that race.
|
|
146
|
+
try {
|
|
147
|
+
await verifyNativeWaylandWindowClosed(row, 400);
|
|
148
|
+
return jsonResult({ action, backend:'atspi-window-action', id:row.id, accessibility_action:'window.close', verified_after_disconnect:true });
|
|
149
|
+
} catch {}
|
|
150
|
+
await focusNativeWaylandForAction(row, portalBackend);
|
|
151
|
+
await portalShortcut('ALT+F4', { timeoutMs });
|
|
152
|
+
await verifyNativeWaylandWindowClosed(row);
|
|
153
|
+
return jsonResult({ action, backend:'xdg-desktop-portal', id:row.id });
|
|
154
|
+
}
|
|
155
|
+
if (action === 'minimize') {
|
|
156
|
+
await focusNativeWaylandForAction(row, portalBackend);
|
|
157
|
+
await portalShortcut('ALT+F9', { timeoutMs });
|
|
158
|
+
}
|
|
159
|
+
else if (action === 'maximize' || action === 'restore') {
|
|
160
|
+
let maximized = false;
|
|
161
|
+
try {
|
|
162
|
+
const displays = JSON.parse((await displayInventory()).content?.[0]?.text || '[]');
|
|
163
|
+
const cx = Number(row.x) + Number(row.width) / 2;
|
|
164
|
+
const cy = Number(row.y) + Number(row.height) / 2;
|
|
165
|
+
const display = displays.find(item =>
|
|
166
|
+
Number.isFinite(cx) && Number.isFinite(cy)
|
|
167
|
+
&& cx >= Number(item.x) && cx < Number(item.x) + Number(item.width)
|
|
168
|
+
&& cy >= Number(item.y) && cy < Number(item.y) + Number(item.height)
|
|
169
|
+
) || displays.find(item => item.primary) || displays[0];
|
|
170
|
+
if (display) {
|
|
171
|
+
const tolerance = 24;
|
|
172
|
+
const widthClose = Math.abs(Number(row.width) - Number(display.width)) <= tolerance;
|
|
173
|
+
const heightClose = Number(row.height) >= Number(display.height) - 100;
|
|
174
|
+
const xClose = Math.abs(Number(row.x) - Number(display.x)) <= tolerance;
|
|
175
|
+
const yClose = Number(row.y) >= Number(display.y) && Number(row.y) - Number(display.y) <= 100;
|
|
176
|
+
maximized = widthClose && heightClose && xClose && yClose;
|
|
177
|
+
}
|
|
178
|
+
} catch {}
|
|
179
|
+
if ((action === 'maximize' && !maximized) || (action === 'restore' && maximized)) {
|
|
180
|
+
await focusNativeWaylandForAction(row, portalBackend);
|
|
181
|
+
await portalShortcut('ALT+F10', { timeoutMs });
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
await focusNativeWaylandForAction(row, portalBackend);
|
|
185
|
+
let currentX = Number(row.x), currentY = Number(row.y), currentWidth = Number(row.width), currentHeight = Number(row.height);
|
|
186
|
+
if (![currentX,currentY,currentWidth,currentHeight].every(Number.isFinite)) unavailable('Native Wayland window geometry', 'accessibility bounds are unavailable');
|
|
187
|
+
|
|
188
|
+
if (action === 'move' || action === 'move_resize') {
|
|
189
|
+
const x = Number(args.x), y = Number(args.y);
|
|
190
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('x and y are required for native Wayland window move');
|
|
191
|
+
const titleOffset = Math.min(18, Math.max(10, currentHeight / 20));
|
|
192
|
+
await dragDrop({
|
|
193
|
+
from_x:currentX + currentWidth / 2,
|
|
194
|
+
from_y:currentY + titleOffset,
|
|
195
|
+
to_x:x + currentWidth / 2,
|
|
196
|
+
to_y:y + titleOffset,
|
|
197
|
+
backend:portalBackend,
|
|
198
|
+
hold_ms:80,
|
|
199
|
+
duration_ms:120,
|
|
200
|
+
});
|
|
201
|
+
const verified = await verifyNativeWaylandGeometry(row, { x, y }, 'move');
|
|
202
|
+
currentX = Number(verified.x);
|
|
203
|
+
currentY = Number(verified.y);
|
|
204
|
+
currentWidth = Number(verified.width);
|
|
205
|
+
currentHeight = Number(verified.height);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (action === 'resize' || action === 'move_resize') {
|
|
209
|
+
const width = Number(args.width), height = Number(args.height);
|
|
210
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) throw new Error('width and height must be positive for native Wayland window resize');
|
|
211
|
+
await dragDrop({
|
|
212
|
+
from_x:currentX + currentWidth - 4,
|
|
213
|
+
from_y:currentY + currentHeight - 4,
|
|
214
|
+
to_x:currentX + width - 4,
|
|
215
|
+
to_y:currentY + height - 4,
|
|
216
|
+
backend:portalBackend,
|
|
217
|
+
hold_ms:80,
|
|
218
|
+
duration_ms:120,
|
|
219
|
+
});
|
|
220
|
+
const verified = await verifyNativeWaylandGeometry(row, { width, height }, 'resize');
|
|
221
|
+
currentX = Number(verified.x);
|
|
222
|
+
currentY = Number(verified.y);
|
|
223
|
+
currentWidth = Number(verified.width);
|
|
224
|
+
currentHeight = Number(verified.height);
|
|
225
|
+
}
|
|
226
|
+
return jsonResult({
|
|
227
|
+
action,
|
|
228
|
+
backend:'xdg-desktop-portal',
|
|
229
|
+
id:row.id,
|
|
230
|
+
x:currentX,
|
|
231
|
+
y:currentY,
|
|
232
|
+
width:currentWidth,
|
|
233
|
+
height:currentHeight,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return jsonResult({ action, backend:'xdg-desktop-portal', id:row.id });
|
|
237
|
+
}
|