@remcp/runtime 0.2.154 → 0.2.156

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,321 @@
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 { uiSnapshot } from './desktop-linux-ui.mjs';
25
+
26
+ export function parseWindows(stdout) {
27
+ const rows = [];
28
+ for (const line of String(stdout).split(/\r?\n/)) {
29
+ const match = line.match(/^(0x[0-9a-f]+)\s+\S+\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+\S+\s*(.*)$/i);
30
+ if (!match) continue;
31
+ rows.push({
32
+ id: match[1],
33
+ pid: Number(match[2]),
34
+ x: Number(match[3]),
35
+ y: Number(match[4]),
36
+ width: Number(match[5]),
37
+ height: Number(match[6]),
38
+ app: match[7],
39
+ title: match[8],
40
+ });
41
+ }
42
+ return rows;
43
+ }
44
+
45
+ export function parseX11PixelBounds(stdout) {
46
+ const x = Number(String(stdout).match(/Absolute upper-left X:\s*(-?\d+)/)?.[1]);
47
+ const y = Number(String(stdout).match(/Absolute upper-left Y:\s*(-?\d+)/)?.[1]);
48
+ const width = Number(String(stdout).match(/^\s*Width:\s*(\d+)/m)?.[1]);
49
+ const height = Number(String(stdout).match(/^\s*Height:\s*(\d+)/m)?.[1]);
50
+ if (![x,y,width,height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
51
+ return { x, y, width, height };
52
+ }
53
+
54
+ export async function x11PixelBounds(id) {
55
+ if (!commandExists('xwininfo')) return null;
56
+ const { stdout } = await runFile('xwininfo', ['-id', String(id)], { label:'X11 window geometry', allowFailure:true, timeout:1000 });
57
+ return parseX11PixelBounds(stdout);
58
+ }
59
+
60
+ export async function listWindows() {
61
+ let wmRows = commandExists('wmctrl')
62
+ ? parseWindows((await runFile('wmctrl', ['-lpGx'], { label: 'window inventory', allowFailure:true, timeout:3000 })).stdout)
63
+ : [];
64
+ if (!isWaylandSession()) {
65
+ if (!wmRows.length && !commandExists('wmctrl')) unavailable('Window inventory', 'install wmctrl');
66
+ return jsonResult(wmRows.map(row => ({
67
+ ...row,
68
+ wm_id: row.id,
69
+ ui_id: null,
70
+ backend: 'x11',
71
+ actions: ['focus','minimize','maximize','restore','move','resize','move_resize','close'],
72
+ })));
73
+ }
74
+
75
+ let uiRows = [];
76
+ try {
77
+ const snapshot = await uiSnapshot({ max_nodes: 2500, max_depth: 1 });
78
+ const payload = JSON.parse(snapshot.content?.[0]?.text || '{}');
79
+ const nodes = Array.isArray(payload) ? payload : payload.nodes || [];
80
+ uiRows = nodes.filter(node => {
81
+ const role = String(node.role || '').toLowerCase();
82
+ return ['frame','window','dialog'].includes(role)
83
+ && String(node.name || node.window || '').trim()
84
+ && Number(node.width) > 0
85
+ && Number(node.height) > 0
86
+ && Number(node.x) > -1000000
87
+ && Number(node.y) > -1000000;
88
+ });
89
+ } catch {}
90
+
91
+ // AT-SPI enumeration can take several seconds on a busy desktop. Refresh the cheap
92
+ // X11 inventory afterwards so an XWayland window that appeared while AT-SPI was
93
+ // walking the tree is merged with its semantic window instead of being misclassified
94
+ // as native Wayland for the rest of this call.
95
+ if (commandExists('wmctrl')) {
96
+ const refreshedWmRows = parseWindows((await runFile('wmctrl', ['-lpGx'], { label:'window inventory refresh', allowFailure:true, timeout:3000 })).stdout);
97
+ if (refreshedWmRows.length) wmRows = refreshedWmRows;
98
+ }
99
+
100
+ const pixelBounds = new Map();
101
+ if (commandExists('xwininfo')) {
102
+ // wmctrl geometry on Mutter/XWayland is decoration-shifted and cannot be used for pixel crops.
103
+ // xwininfo reports the actual client-area coordinates in the compositor screenshot space.
104
+ for (const row of wmRows) {
105
+ try {
106
+ const bounds = await x11PixelBounds(row.id);
107
+ if (bounds) pixelBounds.set(row.id, bounds);
108
+ } catch {}
109
+ }
110
+ }
111
+
112
+ const usedUi = new Set();
113
+ const rows = wmRows.map(row => {
114
+ const matchIndex = uiRows.findIndex((node, index) => {
115
+ if (usedUi.has(index)) return false;
116
+ if (Number(node.pid) > 0 && Number(row.pid) > 0 && Number(node.pid) !== Number(row.pid)) return false;
117
+ const nodeTitle = String(node.name || node.window || '').trim().toLowerCase();
118
+ const rowTitle = String(row.title || '').trim().toLowerCase();
119
+ return nodeTitle && rowTitle && (nodeTitle === rowTitle || nodeTitle.includes(rowTitle) || rowTitle.includes(nodeTitle));
120
+ });
121
+ const node = matchIndex >= 0 ? uiRows[matchIndex] : null;
122
+ if (matchIndex >= 0) usedUi.add(matchIndex);
123
+ const xBounds = pixelBounds.get(row.id) || null;
124
+ return {
125
+ ...row,
126
+ ...(xBounds || {}),
127
+ pid: Number(row.pid) > 0 ? Number(row.pid) : (Number(node?.pid) > 0 ? Number(node.pid) : null),
128
+ wm_bounds: xBounds ? { x:row.x, y:row.y, width:row.width, height:row.height } : null,
129
+ geometry_source: xBounds ? 'xwininfo' : 'wmctrl',
130
+ wm_id: row.id,
131
+ ui_id: node?.id || null,
132
+ backend: node ? 'xwayland+atspi' : 'xwayland',
133
+ actions: ['focus','minimize','maximize','restore','move','resize','move_resize','close'],
134
+ action_backends: isWaylandSession()
135
+ ? { focus:'xdg-desktop-portal', minimize:'wmctrl', maximize:'wmctrl', restore:'wmctrl', move:'wmctrl', resize:'wmctrl', move_resize:'wmctrl', close:'wmctrl' }
136
+ : { focus:'wmctrl', minimize:'wmctrl', maximize:'wmctrl', restore:'wmctrl', move:'wmctrl', resize:'wmctrl', move_resize:'wmctrl', close:'wmctrl' },
137
+ focus_requires_portal_permission: isWaylandSession(),
138
+ };
139
+ });
140
+
141
+ for (let index = 0; index < uiRows.length; index += 1) {
142
+ if (usedUi.has(index)) continue;
143
+ const node = uiRows[index];
144
+ rows.push({
145
+ id: node.id,
146
+ wm_id: null,
147
+ ui_id: node.id,
148
+ pid: Number(node.pid) || null,
149
+ x: Number(node.x),
150
+ y: Number(node.y),
151
+ width: Number(node.width),
152
+ height: Number(node.height),
153
+ app: node.app || null,
154
+ title: node.name || node.window || '',
155
+ backend: 'wayland-atspi',
156
+ actions: ['focus','minimize','maximize','restore','move','resize','move_resize','close'],
157
+ action_backend: 'xdg-desktop-portal',
158
+ requires_portal_permission: true,
159
+ });
160
+ }
161
+
162
+ if (!rows.length) unavailable('Window inventory', 'wmctrl and AT-SPI exposed no top-level windows');
163
+ return jsonResult(rows);
164
+ }
165
+
166
+ export function windowSelector(args = {}) {
167
+ return {
168
+ id: optionalString(args.id || args.window_id || args.windowId),
169
+ pid: Number.isInteger(Number(args.pid)) ? Number(args.pid) : null,
170
+ app: optionalString(args.app),
171
+ title: optionalString(args.title || args.window_title || args.windowTitle),
172
+ };
173
+ }
174
+
175
+ export function windowMatchesSelector(item, selector) {
176
+ return (!selector.id || item.id === selector.id || item.wm_id === selector.id || item.ui_id === selector.id)
177
+ && (selector.pid == null || Number(item.pid) === selector.pid)
178
+ && (!selector.app || String(item.app || '').toLowerCase().includes(selector.app.toLowerCase()))
179
+ && (!selector.title || String(item.title || '').toLowerCase().includes(selector.title.toLowerCase()));
180
+ }
181
+
182
+ export async function fastX11WindowMatch(args) {
183
+ if (!commandExists('wmctrl')) return null;
184
+ const selector = windowSelector(args);
185
+ const rows = parseWindows((await runFile('wmctrl', ['-lpGx'], {
186
+ label:'window match',
187
+ allowFailure:true,
188
+ timeout:2000,
189
+ })).stdout);
190
+ const row = rows.find(item => windowMatchesSelector({ ...item, wm_id:item.id, ui_id:null }, selector));
191
+ if (!row) return null;
192
+ return {
193
+ ...row,
194
+ wm_id:row.id,
195
+ ui_id:null,
196
+ backend:isWaylandSession() ? 'xwayland' : 'x11',
197
+ };
198
+ }
199
+
200
+ export async function windowMatch(args) {
201
+ // Most desktop actions target an X11/XWayland window. Resolve that cheap native id
202
+ // first instead of rebuilding the entire AT-SPI inventory before every action.
203
+ // Native Wayland windows have no wmctrl row and fall through to the semantic inventory.
204
+ const fast = await fastX11WindowMatch(args);
205
+ if (fast) return fast;
206
+
207
+ const rows = JSON.parse((await listWindows()).content[0].text);
208
+ const selector = windowSelector(args);
209
+ const row = rows.find(item => windowMatchesSelector(item, selector));
210
+ if (!row) throw new Error('No matching window found');
211
+ return row;
212
+ }
213
+
214
+ export async function focusedAccessibilityContext() {
215
+ if (!commandExists('python3')) return null;
216
+ const script = String.raw`import gi,json
217
+ gi.require_version("Atspi","2.0")
218
+ from gi.repository import Atspi
219
+ desktop=Atspi.get_desktop(0)
220
+ queue=[];out=[];seen=0
221
+ for i in range(desktop.get_child_count()):
222
+ app=desktop.get_child_at_index(i)
223
+ try:
224
+ app_name=app.get_name() or ""
225
+ pid=int(app.get_process_id())
226
+ queue.append((app,app_name,pid,""))
227
+ except Exception: pass
228
+ while queue and seen<12000:
229
+ node,app_name,pid,window_name=queue.pop(0);seen+=1
230
+ try:
231
+ role=node.get_role_name() or ""
232
+ name=node.get_name() or ""
233
+ if role.lower() in ("frame","window","dialog") and name: window_name=name
234
+ if node.get_state_set().contains(Atspi.StateType.FOCUSED) and pid>0:
235
+ out.append({"pid":pid,"app":app_name,"window":window_name,"role":role,"name":name})
236
+ for i in range(node.get_child_count()): queue.append((node.get_child_at_index(i),app_name,pid,window_name))
237
+ except Exception: pass
238
+ print(json.dumps(out))`;
239
+ const result = await runFile('python3', ['-c', script], { label:'focused accessibility context', allowFailure:true, timeout:2500 });
240
+ let rows = [];
241
+ try { rows = JSON.parse(result.stdout || '[]'); } catch {}
242
+ if (!Array.isArray(rows) || !rows.length) return null;
243
+ const score = row => {
244
+ const app = String(row.app || '').toLowerCase();
245
+ const windowName = String(row.window || '').trim();
246
+ const role = String(row.role || '').toLowerCase();
247
+ let value = windowName ? 100 : 0;
248
+ if (app && app !== 'gnome-shell') value += 40;
249
+ if (row.name) value += 15;
250
+ if (!['window','filler','panel'].includes(role)) value += 15;
251
+ if (/desktop icons/i.test(windowName)) value -= 80;
252
+ if (app === 'gnome-shell' && !windowName) value -= 120;
253
+ return value;
254
+ };
255
+ return rows.sort((a,b) => score(b)-score(a))[0] || null;
256
+ }
257
+
258
+ export async function focusedAccessibilityPid() {
259
+ return Number((await focusedAccessibilityContext())?.pid) || null;
260
+ }
261
+
262
+ export async function targetAccessibilityFocus(row) {
263
+ const pid = Number(row?.pid);
264
+ if (!Number.isInteger(pid) || pid <= 0) return { focused:null, exact:null };
265
+ try {
266
+ const result = await uiSnapshot({ pid, max_nodes: 2200, max_depth: 24 });
267
+ const payload = JSON.parse(result.content?.[0]?.text || '{}');
268
+ const nodes = Array.isArray(payload) ? payload : payload.nodes || [];
269
+ const topLevelActive = nodes.filter(node =>
270
+ node.active === true
271
+ && /frame|window|dialog/i.test(String(node.role || ''))
272
+ && String(node.window || node.name || '').trim()
273
+ );
274
+ const active = topLevelActive[0] || null;
275
+ const wanted = String(row.title || '').trim().toLowerCase();
276
+ const exact = !wanted ? active : topLevelActive.find(node => {
277
+ const current = String(node.window || node.name || '').trim().toLowerCase();
278
+ return current && (wanted === current || wanted.includes(current) || current.includes(wanted));
279
+ }) || null;
280
+ return { active, exact };
281
+ } catch {
282
+ return { active:null, exact:null };
283
+ }
284
+ }
285
+
286
+ export async function inputWindowId(args = {}) {
287
+ const explicit = Boolean(args.window_id || args.windowId || args.id || args.pid != null || args.app || args.title || args.window_title || args.windowTitle);
288
+ if (explicit) return (await windowMatch(args)).wm_id || null;
289
+ if (!isWaylandSession()) return null;
290
+ const pid = await focusedAccessibilityPid();
291
+ if (!pid) return null;
292
+ try { return (await windowMatch({ pid })).wm_id || null; } catch { return null; }
293
+ }
294
+
295
+ export async function focusInputWindow(windowId) {
296
+ if (!windowId || !commandExists('wmctrl')) return;
297
+ const result = await runFile('wmctrl', ['-ia', windowId], { label:'input window focus', allowFailure:true, timeout:2000 });
298
+ if (result.code === 0) await new Promise(resolve => setTimeout(resolve, 40));
299
+ }
300
+
301
+ export function explicitInputWindowSelector(args = {}) {
302
+ const dedicatedWindowId = optionalString(args.window_id || args.windowId);
303
+ const dedicatedTitle = optionalString(args.title);
304
+ const hasSemanticElementTarget = Boolean(
305
+ optionalString(args.id)
306
+ || optionalString(args.name)
307
+ || optionalString(args.role)
308
+ || optionalString(args.automation_id || args.automationId)
309
+ );
310
+ if (!dedicatedWindowId && !dedicatedTitle && hasSemanticElementTarget) return null;
311
+ const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
312
+ const app = optionalString(args.app);
313
+ const scopedTitle = optionalString(args.window_title || args.windowTitle);
314
+ if (!dedicatedWindowId && !dedicatedTitle && pid == null && !app && !scopedTitle) return null;
315
+ return {
316
+ ...(dedicatedWindowId ? { id:dedicatedWindowId } : {}),
317
+ ...(pid != null ? { pid } : {}),
318
+ ...(app ? { app } : {}),
319
+ ...((dedicatedTitle || scopedTitle) ? { title:dedicatedTitle || scopedTitle } : {}),
320
+ };
321
+ }