@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.
@@ -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
+ }