@remcp/runtime 0.2.47 → 0.2.48
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 +15 -0
- package/README.md +42 -25
- package/package.json +6 -2
- package/src/catalog.mjs +1 -1
- package/src/extended/browser.mjs +592 -0
- package/src/extended/catalog.mjs +564 -0
- package/src/extended/common.mjs +209 -0
- package/src/extended/desktop-linux.mjs +1173 -0
- package/src/extended/desktop-macos.mjs +408 -0
- package/src/extended/desktop-windows.mjs +546 -0
- package/src/extended/desktop.mjs +1313 -0
- package/src/extended/diagnostics.mjs +274 -0
- package/src/extended/documents.mjs +381 -0
- package/src/extended/ocr.mjs +139 -0
- package/src/helpers/wayland-eis-helper.py +450 -0
- package/src/index.mjs +46 -44
- package/src/invoke.mjs +3 -3
- package/src/mcp-v2.mjs +104 -0
- package/src/permissions.mjs +1 -1
- package/src/util.mjs +24 -1
- package/src/wayland-remote-desktop.mjs +654 -0
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { image, multi, text } from '../util.mjs';
|
|
5
|
+
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
6
|
+
import { hasWaylandRemoteDesktopGrant, portalPointerButton, portalPointerMotion, portalScroll, portalShortcut, portalTypeText, waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
|
|
7
|
+
import {
|
|
8
|
+
clamp,
|
|
9
|
+
commandExists,
|
|
10
|
+
jsonResult,
|
|
11
|
+
optionalString,
|
|
12
|
+
removeTemp,
|
|
13
|
+
requireEnum,
|
|
14
|
+
runFile,
|
|
15
|
+
spawnDetached,
|
|
16
|
+
spawnDetachedWithInput,
|
|
17
|
+
tempDir,
|
|
18
|
+
unavailable,
|
|
19
|
+
} from './common.mjs';
|
|
20
|
+
|
|
21
|
+
function parseWindows(stdout) {
|
|
22
|
+
const rows = [];
|
|
23
|
+
for (const line of String(stdout).split(/\r?\n/)) {
|
|
24
|
+
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);
|
|
25
|
+
if (!match) continue;
|
|
26
|
+
rows.push({
|
|
27
|
+
id: match[1],
|
|
28
|
+
pid: Number(match[2]),
|
|
29
|
+
x: Number(match[3]),
|
|
30
|
+
y: Number(match[4]),
|
|
31
|
+
width: Number(match[5]),
|
|
32
|
+
height: Number(match[6]),
|
|
33
|
+
app: match[7],
|
|
34
|
+
title: match[8],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return rows;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseX11PixelBounds(stdout) {
|
|
41
|
+
const x = Number(String(stdout).match(/Absolute upper-left X:\s*(-?\d+)/)?.[1]);
|
|
42
|
+
const y = Number(String(stdout).match(/Absolute upper-left Y:\s*(-?\d+)/)?.[1]);
|
|
43
|
+
const width = Number(String(stdout).match(/^\s*Width:\s*(\d+)/m)?.[1]);
|
|
44
|
+
const height = Number(String(stdout).match(/^\s*Height:\s*(\d+)/m)?.[1]);
|
|
45
|
+
if (![x,y,width,height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
|
|
46
|
+
return { x, y, width, height };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function x11PixelBounds(id) {
|
|
50
|
+
if (!commandExists('xwininfo')) return null;
|
|
51
|
+
const { stdout } = await runFile('xwininfo', ['-id', String(id)], { label:'X11 window geometry', allowFailure:true, timeout:1000 });
|
|
52
|
+
return parseX11PixelBounds(stdout);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listWindows() {
|
|
56
|
+
const wmRows = commandExists('wmctrl')
|
|
57
|
+
? parseWindows((await runFile('wmctrl', ['-lpGx'], { label: 'window inventory', allowFailure:true, timeout:3000 })).stdout)
|
|
58
|
+
: [];
|
|
59
|
+
if (!isWaylandSession()) {
|
|
60
|
+
if (!wmRows.length && !commandExists('wmctrl')) unavailable('Window inventory', 'install wmctrl');
|
|
61
|
+
return jsonResult(wmRows.map(row => ({
|
|
62
|
+
...row,
|
|
63
|
+
wm_id: row.id,
|
|
64
|
+
ui_id: null,
|
|
65
|
+
backend: 'x11',
|
|
66
|
+
actions: ['focus','minimize','maximize','restore','move','resize','move_resize','close'],
|
|
67
|
+
})));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let uiRows = [];
|
|
71
|
+
try {
|
|
72
|
+
const snapshot = await uiSnapshot({ max_nodes: 2500, max_depth: 3 });
|
|
73
|
+
const payload = JSON.parse(snapshot.content?.[0]?.text || '{}');
|
|
74
|
+
const nodes = Array.isArray(payload) ? payload : payload.nodes || [];
|
|
75
|
+
uiRows = nodes.filter(node => {
|
|
76
|
+
const role = String(node.role || '').toLowerCase();
|
|
77
|
+
return ['frame','window','dialog'].includes(role)
|
|
78
|
+
&& String(node.name || node.window || '').trim()
|
|
79
|
+
&& Number(node.width) > 0
|
|
80
|
+
&& Number(node.height) > 0
|
|
81
|
+
&& Number(node.x) > -1000000
|
|
82
|
+
&& Number(node.y) > -1000000;
|
|
83
|
+
});
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
86
|
+
const pixelBounds = new Map();
|
|
87
|
+
if (commandExists('xwininfo')) {
|
|
88
|
+
// wmctrl geometry on Mutter/XWayland is decoration-shifted and cannot be used for pixel crops.
|
|
89
|
+
// xwininfo reports the actual client-area coordinates in the compositor screenshot space.
|
|
90
|
+
for (const row of wmRows) {
|
|
91
|
+
try {
|
|
92
|
+
const bounds = await x11PixelBounds(row.id);
|
|
93
|
+
if (bounds) pixelBounds.set(row.id, bounds);
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const usedUi = new Set();
|
|
99
|
+
const rows = wmRows.map(row => {
|
|
100
|
+
const matchIndex = uiRows.findIndex((node, index) => {
|
|
101
|
+
if (usedUi.has(index)) return false;
|
|
102
|
+
if (Number(node.pid) > 0 && Number(row.pid) > 0 && Number(node.pid) !== Number(row.pid)) return false;
|
|
103
|
+
const nodeTitle = String(node.name || node.window || '').trim().toLowerCase();
|
|
104
|
+
const rowTitle = String(row.title || '').trim().toLowerCase();
|
|
105
|
+
return nodeTitle && rowTitle && (nodeTitle === rowTitle || nodeTitle.includes(rowTitle) || rowTitle.includes(nodeTitle));
|
|
106
|
+
});
|
|
107
|
+
const node = matchIndex >= 0 ? uiRows[matchIndex] : null;
|
|
108
|
+
if (matchIndex >= 0) usedUi.add(matchIndex);
|
|
109
|
+
const xBounds = pixelBounds.get(row.id) || null;
|
|
110
|
+
return {
|
|
111
|
+
...row,
|
|
112
|
+
...(xBounds || {}),
|
|
113
|
+
pid: Number(row.pid) > 0 ? Number(row.pid) : (Number(node?.pid) > 0 ? Number(node.pid) : null),
|
|
114
|
+
wm_bounds: xBounds ? { x:row.x, y:row.y, width:row.width, height:row.height } : null,
|
|
115
|
+
geometry_source: xBounds ? 'xwininfo' : 'wmctrl',
|
|
116
|
+
wm_id: row.id,
|
|
117
|
+
ui_id: node?.id || null,
|
|
118
|
+
backend: node ? 'xwayland+atspi' : 'xwayland',
|
|
119
|
+
actions: ['focus','minimize','maximize','restore','move','resize','move_resize','close'],
|
|
120
|
+
action_backends: isWaylandSession()
|
|
121
|
+
? { focus:'xdg-desktop-portal', minimize:'wmctrl', maximize:'wmctrl', restore:'wmctrl', move:'wmctrl', resize:'wmctrl', move_resize:'wmctrl', close:'wmctrl' }
|
|
122
|
+
: { focus:'wmctrl', minimize:'wmctrl', maximize:'wmctrl', restore:'wmctrl', move:'wmctrl', resize:'wmctrl', move_resize:'wmctrl', close:'wmctrl' },
|
|
123
|
+
focus_requires_portal_permission: isWaylandSession(),
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
for (let index = 0; index < uiRows.length; index += 1) {
|
|
128
|
+
if (usedUi.has(index)) continue;
|
|
129
|
+
const node = uiRows[index];
|
|
130
|
+
rows.push({
|
|
131
|
+
id: node.id,
|
|
132
|
+
wm_id: null,
|
|
133
|
+
ui_id: node.id,
|
|
134
|
+
pid: Number(node.pid) || null,
|
|
135
|
+
x: Number(node.x),
|
|
136
|
+
y: Number(node.y),
|
|
137
|
+
width: Number(node.width),
|
|
138
|
+
height: Number(node.height),
|
|
139
|
+
app: node.app || null,
|
|
140
|
+
title: node.name || node.window || '',
|
|
141
|
+
backend: 'wayland-atspi',
|
|
142
|
+
actions: ['focus','minimize','maximize','restore','move','resize','move_resize','close'],
|
|
143
|
+
action_backend: 'xdg-desktop-portal',
|
|
144
|
+
requires_portal_permission: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!rows.length) unavailable('Window inventory', 'wmctrl and AT-SPI exposed no top-level windows');
|
|
149
|
+
return jsonResult(rows);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function windowMatch(args) {
|
|
153
|
+
const rows = JSON.parse((await listWindows()).content[0].text);
|
|
154
|
+
const id = optionalString(args.id || args.window_id || args.windowId);
|
|
155
|
+
const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
|
|
156
|
+
const app = optionalString(args.app);
|
|
157
|
+
const title = optionalString(args.title || args.window_title || args.windowTitle);
|
|
158
|
+
const row = rows.find(item =>
|
|
159
|
+
(!id || item.id === id || item.wm_id === id || item.ui_id === id)
|
|
160
|
+
&& (pid == null || item.pid === pid)
|
|
161
|
+
&& (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
|
|
162
|
+
&& (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
|
|
163
|
+
);
|
|
164
|
+
if (!row) throw new Error('No matching window found');
|
|
165
|
+
return row;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function focusedAccessibilityContext() {
|
|
169
|
+
if (!commandExists('python3')) return null;
|
|
170
|
+
const script = String.raw`import gi,json
|
|
171
|
+
gi.require_version("Atspi","2.0")
|
|
172
|
+
from gi.repository import Atspi
|
|
173
|
+
desktop=Atspi.get_desktop(0)
|
|
174
|
+
queue=[];out=[];seen=0
|
|
175
|
+
for i in range(desktop.get_child_count()):
|
|
176
|
+
app=desktop.get_child_at_index(i)
|
|
177
|
+
try:
|
|
178
|
+
app_name=app.get_name() or ""
|
|
179
|
+
pid=int(app.get_process_id())
|
|
180
|
+
queue.append((app,app_name,pid,""))
|
|
181
|
+
except Exception: pass
|
|
182
|
+
while queue and seen<12000:
|
|
183
|
+
node,app_name,pid,window_name=queue.pop(0);seen+=1
|
|
184
|
+
try:
|
|
185
|
+
role=node.get_role_name() or ""
|
|
186
|
+
name=node.get_name() or ""
|
|
187
|
+
if role.lower() in ("frame","window","dialog") and name: window_name=name
|
|
188
|
+
if node.get_state_set().contains(Atspi.StateType.FOCUSED) and pid>0:
|
|
189
|
+
out.append({"pid":pid,"app":app_name,"window":window_name,"role":role,"name":name})
|
|
190
|
+
for i in range(node.get_child_count()): queue.append((node.get_child_at_index(i),app_name,pid,window_name))
|
|
191
|
+
except Exception: pass
|
|
192
|
+
print(json.dumps(out))`;
|
|
193
|
+
const result = await runFile('python3', ['-c', script], { label:'focused accessibility context', allowFailure:true, timeout:2500 });
|
|
194
|
+
let rows = [];
|
|
195
|
+
try { rows = JSON.parse(result.stdout || '[]'); } catch {}
|
|
196
|
+
if (!Array.isArray(rows) || !rows.length) return null;
|
|
197
|
+
const score = row => {
|
|
198
|
+
const app = String(row.app || '').toLowerCase();
|
|
199
|
+
const windowName = String(row.window || '').trim();
|
|
200
|
+
const role = String(row.role || '').toLowerCase();
|
|
201
|
+
let value = windowName ? 100 : 0;
|
|
202
|
+
if (app && app !== 'gnome-shell') value += 40;
|
|
203
|
+
if (row.name) value += 15;
|
|
204
|
+
if (!['window','filler','panel'].includes(role)) value += 15;
|
|
205
|
+
if (/desktop icons/i.test(windowName)) value -= 80;
|
|
206
|
+
if (app === 'gnome-shell' && !windowName) value -= 120;
|
|
207
|
+
return value;
|
|
208
|
+
};
|
|
209
|
+
return rows.sort((a,b) => score(b)-score(a))[0] || null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function focusedAccessibilityPid() {
|
|
213
|
+
return Number((await focusedAccessibilityContext())?.pid) || null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function targetAccessibilityFocus(row) {
|
|
217
|
+
const pid = Number(row?.pid);
|
|
218
|
+
if (!Number.isInteger(pid) || pid <= 0) return { focused:null, exact:null };
|
|
219
|
+
try {
|
|
220
|
+
const result = await uiSnapshot({ pid, max_nodes: 2200, max_depth: 24 });
|
|
221
|
+
const payload = JSON.parse(result.content?.[0]?.text || '{}');
|
|
222
|
+
const nodes = Array.isArray(payload) ? payload : payload.nodes || [];
|
|
223
|
+
const topLevelActive = nodes.filter(node =>
|
|
224
|
+
node.active === true
|
|
225
|
+
&& /frame|window|dialog/i.test(String(node.role || ''))
|
|
226
|
+
&& String(node.window || node.name || '').trim()
|
|
227
|
+
);
|
|
228
|
+
const active = topLevelActive[0] || null;
|
|
229
|
+
const wanted = String(row.title || '').trim().toLowerCase();
|
|
230
|
+
const exact = !wanted ? active : topLevelActive.find(node => {
|
|
231
|
+
const current = String(node.window || node.name || '').trim().toLowerCase();
|
|
232
|
+
return current && (wanted === current || wanted.includes(current) || current.includes(wanted));
|
|
233
|
+
}) || null;
|
|
234
|
+
return { active, exact };
|
|
235
|
+
} catch {
|
|
236
|
+
return { active:null, exact:null };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function inputWindowId(args = {}) {
|
|
241
|
+
const explicit = Boolean(args.window_id || args.windowId || args.id || args.pid != null || args.app || args.title || args.window_title || args.windowTitle);
|
|
242
|
+
if (explicit) return (await windowMatch(args)).wm_id || null;
|
|
243
|
+
if (!isWaylandSession()) return null;
|
|
244
|
+
const pid = await focusedAccessibilityPid();
|
|
245
|
+
if (!pid) return null;
|
|
246
|
+
try { return (await windowMatch({ pid })).wm_id || null; } catch { return null; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function focusInputWindow(windowId) {
|
|
250
|
+
if (!windowId || !commandExists('wmctrl')) return;
|
|
251
|
+
const result = await runFile('wmctrl', ['-ia', windowId], { label:'input window focus', allowFailure:true, timeout:2000 });
|
|
252
|
+
if (result.code === 0) await new Promise(resolve => setTimeout(resolve, 40));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function nativeWaylandWindowAction(row, action, args) {
|
|
256
|
+
const backend = inputBackend(args);
|
|
257
|
+
if (backend === 'x11') unavailable('Native Wayland window action', 'use backend=portal for compositor-managed windows');
|
|
258
|
+
if (!waylandPortalCandidate()) unavailable('Native Wayland window action', 'no XDG RemoteDesktop portal is available');
|
|
259
|
+
if (backend === 'auto' && !hasWaylandRemoteDesktopGrant()) portalPermissionHint('Native Wayland window action');
|
|
260
|
+
if (row.ui_id) {
|
|
261
|
+
try { await uiAction({ action:'focus', id:row.ui_id }); } catch {}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const portalBackend = backend === 'portal' ? 'portal' : 'auto';
|
|
265
|
+
const timeoutMs = backend === 'portal' ? 120_000 : 2500;
|
|
266
|
+
if (action === 'close') await portalShortcut('ALT+F4', { timeoutMs });
|
|
267
|
+
else if (action === 'minimize') await portalShortcut('ALT+F9', { timeoutMs });
|
|
268
|
+
else if (action === 'maximize' || action === 'restore') {
|
|
269
|
+
let maximized = false;
|
|
270
|
+
try {
|
|
271
|
+
const displays = JSON.parse((await displayInventory()).content?.[0]?.text || '[]');
|
|
272
|
+
const cx = Number(row.x) + Number(row.width) / 2;
|
|
273
|
+
const cy = Number(row.y) + Number(row.height) / 2;
|
|
274
|
+
const display = displays.find(item =>
|
|
275
|
+
Number.isFinite(cx) && Number.isFinite(cy)
|
|
276
|
+
&& cx >= Number(item.x) && cx < Number(item.x) + Number(item.width)
|
|
277
|
+
&& cy >= Number(item.y) && cy < Number(item.y) + Number(item.height)
|
|
278
|
+
) || displays.find(item => item.primary) || displays[0];
|
|
279
|
+
if (display) {
|
|
280
|
+
const tolerance = 24;
|
|
281
|
+
const widthClose = Math.abs(Number(row.width) - Number(display.width)) <= tolerance;
|
|
282
|
+
const heightClose = Number(row.height) >= Number(display.height) - 100;
|
|
283
|
+
const xClose = Math.abs(Number(row.x) - Number(display.x)) <= tolerance;
|
|
284
|
+
const yClose = Number(row.y) >= Number(display.y) && Number(row.y) - Number(display.y) <= 100;
|
|
285
|
+
maximized = widthClose && heightClose && xClose && yClose;
|
|
286
|
+
}
|
|
287
|
+
} catch {}
|
|
288
|
+
if ((action === 'maximize' && !maximized) || (action === 'restore' && maximized)) {
|
|
289
|
+
await portalShortcut('ALT+F10', { timeoutMs });
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
let currentX = Number(row.x), currentY = Number(row.y), currentWidth = Number(row.width), currentHeight = Number(row.height);
|
|
293
|
+
if (![currentX,currentY,currentWidth,currentHeight].every(Number.isFinite)) unavailable('Native Wayland window geometry', 'accessibility bounds are unavailable');
|
|
294
|
+
|
|
295
|
+
if (action === 'move' || action === 'move_resize') {
|
|
296
|
+
const x = Number(args.x), y = Number(args.y);
|
|
297
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('x and y are required for native Wayland window move');
|
|
298
|
+
const titleOffset = Math.min(18, Math.max(10, currentHeight / 20));
|
|
299
|
+
await dragDrop({
|
|
300
|
+
from_x:currentX + currentWidth / 2,
|
|
301
|
+
from_y:currentY + titleOffset,
|
|
302
|
+
to_x:x + currentWidth / 2,
|
|
303
|
+
to_y:y + titleOffset,
|
|
304
|
+
backend:portalBackend,
|
|
305
|
+
hold_ms:80,
|
|
306
|
+
duration_ms:120,
|
|
307
|
+
});
|
|
308
|
+
currentX = x;
|
|
309
|
+
currentY = y;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (action === 'resize' || action === 'move_resize') {
|
|
313
|
+
const width = Number(args.width), height = Number(args.height);
|
|
314
|
+
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');
|
|
315
|
+
await dragDrop({
|
|
316
|
+
from_x:currentX + currentWidth - 4,
|
|
317
|
+
from_y:currentY + currentHeight - 4,
|
|
318
|
+
to_x:currentX + width - 4,
|
|
319
|
+
to_y:currentY + height - 4,
|
|
320
|
+
backend:portalBackend,
|
|
321
|
+
hold_ms:80,
|
|
322
|
+
duration_ms:120,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return jsonResult({ action, backend:'xdg-desktop-portal', id:row.id });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function windowAction(args) {
|
|
330
|
+
const action = requireEnum(args.action, 'action', ['focus','minimize','maximize','restore','move','resize','move_resize','close']);
|
|
331
|
+
const row = await windowMatch(args);
|
|
332
|
+
const id = row.wm_id || row.id;
|
|
333
|
+
const nativeWayland = isWaylandSession() && !row.wm_id && !/^0x/i.test(String(id || ''));
|
|
334
|
+
if (action === 'focus') {
|
|
335
|
+
if (isWaylandSession()) {
|
|
336
|
+
const backend = inputBackend(args);
|
|
337
|
+
if (row.ui_id && backend !== 'portal') {
|
|
338
|
+
try {
|
|
339
|
+
await uiAction({ action:'focus', id:row.ui_id });
|
|
340
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
341
|
+
const focusState = await targetAccessibilityFocus(row);
|
|
342
|
+
if (focusState.exact) return text('Window action focus completed via verified AT-SPI focus.');
|
|
343
|
+
} catch {}
|
|
344
|
+
}
|
|
345
|
+
if (backend === 'x11') unavailable('Wayland window focus', 'AT-SPI focus did not take effect and X11 focus cannot target this Wayland window; use backend=portal');
|
|
346
|
+
if (!waylandPortalCandidate()) unavailable('Wayland window focus', 'no XDG RemoteDesktop portal is available');
|
|
347
|
+
if (backend === 'auto' && !hasWaylandRemoteDesktopGrant()) portalPermissionHint('Wayland window focus');
|
|
348
|
+
const portalOptions = { timeoutMs: backend === 'portal' ? 120_000 : 2500 };
|
|
349
|
+
let focusState = await targetAccessibilityFocus(row);
|
|
350
|
+
if (focusState.exact) return text('Window action focus already matched the target window.');
|
|
351
|
+
|
|
352
|
+
const rows = JSON.parse((await listWindows()).content?.[0]?.text || '[]');
|
|
353
|
+
const appCycles = Math.min(30, Math.max(4, new Set(rows.map(item => Number(item.pid) || String(item.app || ''))).size + 2));
|
|
354
|
+
for (let index = 0; index < appCycles; index += 1) {
|
|
355
|
+
await portalShortcut('ALT+TAB', portalOptions);
|
|
356
|
+
await new Promise(resolve => setTimeout(resolve, 120));
|
|
357
|
+
focusState = await targetAccessibilityFocus(row);
|
|
358
|
+
if (focusState.exact) return text('Window action focus completed via verified Wayland Alt+Tab.');
|
|
359
|
+
if (focusState.active) {
|
|
360
|
+
const sameAppWindows = rows.filter(item => Number(item.pid) === Number(row.pid)).length;
|
|
361
|
+
for (let windowIndex = 0; windowIndex < Math.min(12, Math.max(2, sameAppWindows + 2)); windowIndex += 1) {
|
|
362
|
+
await portalShortcut('ALT+`', portalOptions);
|
|
363
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
364
|
+
focusState = await targetAccessibilityFocus(row);
|
|
365
|
+
if (focusState.exact) return text('Window action focus completed via verified Wayland window cycling.');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`Could not verify focus for ${row.title || row.app || row.id} after cycling Wayland applications`);
|
|
370
|
+
}
|
|
371
|
+
if (!commandExists('wmctrl')) unavailable('Window focus', 'install wmctrl');
|
|
372
|
+
await runFile('wmctrl', ['-ia', id], { label: 'window focus' });
|
|
373
|
+
if (commandExists('xdotool')) {
|
|
374
|
+
const activated = await runFile('xdotool', ['windowactivate','--sync',id], { label:'window focus sync', allowFailure:true, timeout:3000 });
|
|
375
|
+
if (activated.code !== 0) await new Promise(resolve => setTimeout(resolve, 80));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else if (nativeWayland) return nativeWaylandWindowAction(row, action, args);
|
|
379
|
+
else if (!commandExists('wmctrl')) unavailable('Window action', 'install wmctrl');
|
|
380
|
+
else if (action === 'close') await runFile('wmctrl', ['-ic', id], { label: 'window close' });
|
|
381
|
+
else if (action === 'minimize') await runFile('wmctrl', ['-ir', id, '-b', 'add,hidden'], { label: 'window minimize' });
|
|
382
|
+
else if (action === 'maximize') await runFile('wmctrl', ['-ir', id, '-b', 'add,maximized_vert,maximized_horz'], { label: 'window maximize' });
|
|
383
|
+
else if (action === 'restore') await runFile('wmctrl', ['-ir', id, '-b', 'remove,hidden,maximized_vert,maximized_horz'], { label: 'window restore' });
|
|
384
|
+
else {
|
|
385
|
+
const x = Number(args.x), y = Number(args.y), width = Number(args.width), height = Number(args.height);
|
|
386
|
+
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}`;
|
|
387
|
+
await runFile('wmctrl', ['-ir', id, '-e', geometry], { label: 'window geometry' });
|
|
388
|
+
}
|
|
389
|
+
return text(`Window action ${action} completed.`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function pyAtSpiPrelude() {
|
|
393
|
+
return `import json,sys,math
|
|
394
|
+
try:
|
|
395
|
+
import pyatspi
|
|
396
|
+
except Exception:
|
|
397
|
+
try:
|
|
398
|
+
import gi
|
|
399
|
+
gi.require_version("Atspi","2.0")
|
|
400
|
+
from gi.repository import Atspi
|
|
401
|
+
class _Component:
|
|
402
|
+
def __init__(self,a): self.a=a
|
|
403
|
+
def getExtents(self,coords): return self.a.get_extents(coords)
|
|
404
|
+
def grabFocus(self): return self.a.grab_focus()
|
|
405
|
+
class _Value:
|
|
406
|
+
def __init__(self,a): self.a=a
|
|
407
|
+
@property
|
|
408
|
+
def currentValue(self): return self.a.get_current_value()
|
|
409
|
+
@currentValue.setter
|
|
410
|
+
def currentValue(self,v): self.a.set_current_value(float(v))
|
|
411
|
+
class _Action:
|
|
412
|
+
def __init__(self,a): self.a=a
|
|
413
|
+
def doAction(self,i): return self.a.do_action(i)
|
|
414
|
+
class _Editable:
|
|
415
|
+
def __init__(self,a): self.a=a
|
|
416
|
+
def setTextContents(self,v): return self.a.set_text_contents(v)
|
|
417
|
+
class _Accessible:
|
|
418
|
+
def __init__(self,a): self.a=a
|
|
419
|
+
@property
|
|
420
|
+
def name(self): return self.a.get_name()
|
|
421
|
+
@property
|
|
422
|
+
def childCount(self): return self.a.get_child_count()
|
|
423
|
+
def getState(self): return self.a.get_state_set()
|
|
424
|
+
def getRoleName(self): return self.a.get_role_name()
|
|
425
|
+
def getChildAtIndex(self,i): return _Accessible(self.a.get_child_at_index(i))
|
|
426
|
+
def queryComponent(self): return _Component(self.a)
|
|
427
|
+
def queryValue(self):
|
|
428
|
+
iface=self.a.get_value_iface()
|
|
429
|
+
if iface is None: raise Exception("Value interface unavailable")
|
|
430
|
+
return _Value(self.a)
|
|
431
|
+
def queryAction(self): return _Action(self.a)
|
|
432
|
+
def queryEditableText(self): return _Editable(self.a)
|
|
433
|
+
def getProcessId(self):
|
|
434
|
+
try: return int(self.a.get_process_id())
|
|
435
|
+
except: return 0
|
|
436
|
+
def __iter__(self):
|
|
437
|
+
for i in range(self.childCount): yield self.getChildAtIndex(i)
|
|
438
|
+
class _Registry:
|
|
439
|
+
@staticmethod
|
|
440
|
+
def getDesktop(i): return _Accessible(Atspi.get_desktop(i))
|
|
441
|
+
class _Compat:
|
|
442
|
+
Registry=_Registry
|
|
443
|
+
STATE_ACTIVE=Atspi.StateType.ACTIVE
|
|
444
|
+
STATE_SHOWING=Atspi.StateType.SHOWING
|
|
445
|
+
STATE_FOCUSED=Atspi.StateType.FOCUSED
|
|
446
|
+
DESKTOP_COORDS=Atspi.CoordType.SCREEN
|
|
447
|
+
pyatspi=_Compat()
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
print(json.dumps({"error":"AT-SPI unavailable: %s"%exc}));sys.exit(3)
|
|
450
|
+
desktop=pyatspi.Registry.getDesktop(0)
|
|
451
|
+
def _proc(e):
|
|
452
|
+
for attr in ("getProcessId","get_process_id"):
|
|
453
|
+
try:
|
|
454
|
+
return int(getattr(e,attr)())
|
|
455
|
+
except: pass
|
|
456
|
+
try:
|
|
457
|
+
app=e.getApplication()
|
|
458
|
+
for attr in ("getProcessId","get_process_id"):
|
|
459
|
+
try: return int(getattr(app,attr)())
|
|
460
|
+
except: pass
|
|
461
|
+
except: pass
|
|
462
|
+
return 0
|
|
463
|
+
q=[]
|
|
464
|
+
for app in desktop:
|
|
465
|
+
try:
|
|
466
|
+
app_name=app.name or ""
|
|
467
|
+
pid=_proc(app)
|
|
468
|
+
active=False
|
|
469
|
+
try:
|
|
470
|
+
active=bool(app.getState().contains(pyatspi.STATE_ACTIVE))
|
|
471
|
+
if not active:
|
|
472
|
+
for child_i in range(min(app.childCount,64)):
|
|
473
|
+
try:
|
|
474
|
+
child=app.getChildAtIndex(child_i)
|
|
475
|
+
if child.getState().contains(pyatspi.STATE_ACTIVE):
|
|
476
|
+
active=True;break
|
|
477
|
+
except: pass
|
|
478
|
+
except: pass
|
|
479
|
+
if app.childCount>0 or pid>0:q.append((app,app_name,pid,active))
|
|
480
|
+
except:pass
|
|
481
|
+
`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export async function uiSnapshot(args = {}) {
|
|
485
|
+
if (!commandExists('python3')) unavailable('Linux accessibility', 'python3 with python3-pyatspi is required');
|
|
486
|
+
const maxNodes = clamp(args.max_nodes, 500, 1, 5000);
|
|
487
|
+
const maxDepth = clamp(args.max_depth, 8, 1, 32);
|
|
488
|
+
const wantedApp = JSON.stringify(optionalString(args.app) || '');
|
|
489
|
+
const wantedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : 0;
|
|
490
|
+
const wantedWindow = JSON.stringify(optionalString(args.window_title || args.windowTitle) || '');
|
|
491
|
+
const activeOnly = args.active_only === true;
|
|
492
|
+
const script = pyAtSpiPrelude() + `
|
|
493
|
+
want_app=${wantedApp};want_pid=${wantedPid};want_window=${wantedWindow};want_active=${activeOnly ? 'True' : 'False'}
|
|
494
|
+
roots=[]
|
|
495
|
+
for root,app_name,pid,active in q:
|
|
496
|
+
if want_pid and pid!=want_pid: continue
|
|
497
|
+
if want_app and want_app.lower() not in app_name.lower(): continue
|
|
498
|
+
if want_active and not active: continue
|
|
499
|
+
roots.append((root,app_name,pid,active))
|
|
500
|
+
out=[];queue=[];seen=0
|
|
501
|
+
for root_index,(e,app_name,pid,active) in enumerate(roots):
|
|
502
|
+
if want_window:
|
|
503
|
+
top_match=False
|
|
504
|
+
try:
|
|
505
|
+
for child in e:
|
|
506
|
+
try:
|
|
507
|
+
child_name=child.name or ""
|
|
508
|
+
child_role=child.getRoleName().lower()
|
|
509
|
+
if child_role in ("frame","dialog","window") and want_window.lower() in child_name.lower():
|
|
510
|
+
top_match=True;break
|
|
511
|
+
except: pass
|
|
512
|
+
except: pass
|
|
513
|
+
if not top_match: continue
|
|
514
|
+
root_path="r" if pid else "a%d"%root_index
|
|
515
|
+
queue.append((e,0,app_name,pid,active,"",root_path))
|
|
516
|
+
while queue and len(out)<${maxNodes}:
|
|
517
|
+
e,d,app_name,pid,active,window_name,node_path=queue.pop(0);current_index=seen;seen+=1
|
|
518
|
+
try: role=e.getRoleName()
|
|
519
|
+
except: role=""
|
|
520
|
+
try: name=e.name or ""
|
|
521
|
+
except: name=""
|
|
522
|
+
if role.lower() in ("frame","dialog","window") and name: window_name=name
|
|
523
|
+
try:
|
|
524
|
+
ext=e.queryComponent().getExtents(pyatspi.DESKTOP_COORDS);box={"x":ext.x,"y":ext.y,"width":ext.width,"height":ext.height}
|
|
525
|
+
except: box={"x":0,"y":0,"width":0,"height":0}
|
|
526
|
+
try:
|
|
527
|
+
value=e.queryValue().currentValue
|
|
528
|
+
if isinstance(value,float) and not math.isfinite(value): value=""
|
|
529
|
+
except: value=""
|
|
530
|
+
focused=False;node_active=active
|
|
531
|
+
try:
|
|
532
|
+
states=e.getState();focused=bool(states.contains(pyatspi.STATE_FOCUSED));node_active=bool(node_active or states.contains(pyatspi.STATE_ACTIVE))
|
|
533
|
+
except: pass
|
|
534
|
+
if not want_window or want_window.lower() in window_name.lower():
|
|
535
|
+
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})
|
|
536
|
+
if d<${maxDepth}:
|
|
537
|
+
try:
|
|
538
|
+
for i in range(e.childCount):queue.append((e.getChildAtIndex(i),d+1,app_name,pid,active,window_name,node_path+"."+str(i)))
|
|
539
|
+
except:pass
|
|
540
|
+
print(json.dumps(out))`;
|
|
541
|
+
const result = await runFile('python3', ['-c', script], { label: 'AT-SPI snapshot', timeout: 30_000, allowFailure: true });
|
|
542
|
+
let parsed = null;
|
|
543
|
+
try { parsed = JSON.parse(result.stdout || ''); } catch {}
|
|
544
|
+
if (Array.isArray(parsed)) return jsonResult({ platform: 'linux', count: parsed.length, nodes: parsed });
|
|
545
|
+
if (parsed?.error) unavailable('Linux accessibility', parsed.error);
|
|
546
|
+
if (result.code !== 0) unavailable('Linux accessibility', (result.stderr || result.stdout).trim());
|
|
547
|
+
return jsonResult({ platform: 'linux', count: 0, nodes: [] });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export async function uiAction(args = {}) {
|
|
551
|
+
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']);
|
|
552
|
+
if (!commandExists('python3')) unavailable('Linux accessibility action', 'python3 with python3-pyatspi is required');
|
|
553
|
+
const id = optionalString(args.id);
|
|
554
|
+
const idParts = id?.split(':') || [];
|
|
555
|
+
const legacyIndex = idParts.length === 2 && idParts[0] === 'linux' && /^\d+$/.test(idParts[1]) ? Number(idParts[1]) : -1;
|
|
556
|
+
const stablePid = idParts.length >= 3 && idParts[0] === 'linux' && /^\d+$/.test(idParts[1]) ? Number(idParts[1]) : 0;
|
|
557
|
+
const stablePath = idParts.length >= 3 && idParts[0] === 'linux' ? idParts.slice(2).join(':') : '';
|
|
558
|
+
const wantedPid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : stablePid;
|
|
559
|
+
const wantedApp = JSON.stringify(optionalString(args.app) || '');
|
|
560
|
+
const wantedWindow = JSON.stringify(optionalString(args.window_title || args.windowTitle) || '');
|
|
561
|
+
const wantedName = JSON.stringify(optionalString(args.name) || '');
|
|
562
|
+
const wantedRole = JSON.stringify(optionalString(args.role) || '');
|
|
563
|
+
const value = JSON.stringify(String(args.value ?? ''));
|
|
564
|
+
const script = pyAtSpiPrelude() + `
|
|
565
|
+
want_index=${legacyIndex};want_pid=${wantedPid};want_path=${JSON.stringify(stablePath)}
|
|
566
|
+
want_app=${wantedApp};want_window=${wantedWindow};want_name=${wantedName};want_role=${wantedRole};action=${JSON.stringify(action)};value=${value}
|
|
567
|
+
queue=[];seen=0;target=None;target_meta={}
|
|
568
|
+
for root_index,(root,app_name,pid,active) in enumerate(q):
|
|
569
|
+
if want_pid and pid!=want_pid: continue
|
|
570
|
+
if want_app and want_app.lower() not in app_name.lower(): continue
|
|
571
|
+
root_path="r" if pid else "a%d"%root_index
|
|
572
|
+
queue.append((root,app_name,pid,"",root_path))
|
|
573
|
+
while queue:
|
|
574
|
+
e,app_name,pid,window_name,node_path=queue.pop(0);current_index=seen;seen+=1
|
|
575
|
+
try: role=e.getRoleName();name=e.name or ""
|
|
576
|
+
except: role="";name=""
|
|
577
|
+
if role.lower() in ("frame","dialog","window") and name: window_name=name
|
|
578
|
+
id_match=(not want_path or (pid==want_pid and node_path==want_path)) and (want_index<0 or current_index==want_index)
|
|
579
|
+
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())
|
|
580
|
+
if id_match and selector_match:
|
|
581
|
+
target=e;target_meta={"pid":pid,"app":app_name,"window":window_name,"path":node_path};break
|
|
582
|
+
try:
|
|
583
|
+
for i in range(e.childCount):queue.append((e.getChildAtIndex(i),app_name,pid,window_name,node_path+"."+str(i)))
|
|
584
|
+
except:pass
|
|
585
|
+
if target is None:raise Exception("UI element not found")
|
|
586
|
+
if action in ("click","invoke","select","toggle"):
|
|
587
|
+
target.queryAction().doAction(0)
|
|
588
|
+
elif action=="focus":
|
|
589
|
+
target.queryComponent().grabFocus()
|
|
590
|
+
elif action=="set_value":
|
|
591
|
+
try: target.queryEditableText().setTextContents(value)
|
|
592
|
+
except: target.queryValue().currentValue=value
|
|
593
|
+
elif action=="set_range_value":
|
|
594
|
+
target.queryValue().currentValue=float(value)
|
|
595
|
+
elif action in ("expand","collapse"):
|
|
596
|
+
actions=target.queryAction();chosen=-1
|
|
597
|
+
for i in range(actions.nActions):
|
|
598
|
+
try:
|
|
599
|
+
action_name=(actions.getName(i) or "").lower()
|
|
600
|
+
if action in action_name: chosen=i;break
|
|
601
|
+
except: pass
|
|
602
|
+
if chosen<0: raise Exception(action+" accessibility action unavailable")
|
|
603
|
+
actions.doAction(chosen)
|
|
604
|
+
elif action=="scroll_into_view":
|
|
605
|
+
component=target.queryComponent()
|
|
606
|
+
try: component.scrollTo(pyatspi.SCROLL_ANYWHERE)
|
|
607
|
+
except:
|
|
608
|
+
if not component.grabFocus(): raise Exception("scroll-to-view unavailable")
|
|
609
|
+
elif action in ("add_to_selection","remove_from_selection"):
|
|
610
|
+
parent=target.parent
|
|
611
|
+
if parent is None: raise Exception("selection parent unavailable")
|
|
612
|
+
selection=parent.querySelection();idx=target.getIndexInParent()
|
|
613
|
+
if action=="add_to_selection":
|
|
614
|
+
if not selection.selectChild(idx): raise Exception("could not add item to selection")
|
|
615
|
+
else:
|
|
616
|
+
if not selection.deselectChild(idx): raise Exception("could not remove item from selection")
|
|
617
|
+
print(json.dumps({"action":action,"name":target.name or "","role":target.getRoleName(),**target_meta}))`;
|
|
618
|
+
const result = await runFile('python3', ['-c', script], { label: 'AT-SPI action', timeout: 30_000, allowFailure: true });
|
|
619
|
+
let payload = null;
|
|
620
|
+
try { payload = JSON.parse(result.stdout || ''); } catch {}
|
|
621
|
+
if (payload && !payload.error) return text(JSON.stringify(payload));
|
|
622
|
+
if (payload?.error) unavailable('Linux accessibility action', payload.error);
|
|
623
|
+
if (result.code !== 0) unavailable('Linux accessibility action', (result.stderr || result.stdout).trim());
|
|
624
|
+
return text(result.stdout.trim());
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function confirmClipboard(expected, backend, timeoutMs = 1500) {
|
|
628
|
+
const deadline = Date.now() + timeoutMs;
|
|
629
|
+
while (Date.now() < deadline) {
|
|
630
|
+
const result = backend === 'wl-copy'
|
|
631
|
+
? await runFile('wl-paste', ['--no-newline'], { label: 'clipboard verify', allowFailure: true, timeout: 500 })
|
|
632
|
+
: await runFile('xclip', ['-selection','clipboard','-o'], { label: 'clipboard verify', allowFailure: true, timeout: 500 });
|
|
633
|
+
if (result.code === 0 && result.stdout === expected) return;
|
|
634
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
635
|
+
}
|
|
636
|
+
throw new Error(`${backend} did not publish the requested clipboard text within ${timeoutMs} ms`);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export async function clipboard(args = {}) {
|
|
640
|
+
const action = requireEnum(args.action, 'action', ['read','write','clear']);
|
|
641
|
+
if (action !== 'read') {
|
|
642
|
+
const value = action === 'clear' ? '' : String(args.text ?? args.value ?? '');
|
|
643
|
+
const failures = [];
|
|
644
|
+
if (commandExists('wl-copy')) {
|
|
645
|
+
try {
|
|
646
|
+
if (action === 'clear') await runFile('wl-copy', ['--clear'], { label: 'clipboard clear', timeout: 3000 });
|
|
647
|
+
else {
|
|
648
|
+
await spawnDetachedWithInput('wl-copy', [], value, { label: 'clipboard write', timeout: 3000 });
|
|
649
|
+
if (commandExists('wl-paste')) await confirmClipboard(value, 'wl-copy');
|
|
650
|
+
}
|
|
651
|
+
return jsonResult({ action, length: value.length, backend: 'wl-copy' });
|
|
652
|
+
} catch (error) { failures.push(`wl-copy: ${error instanceof Error ? error.message : String(error)}`); }
|
|
653
|
+
}
|
|
654
|
+
if (commandExists('xclip')) {
|
|
655
|
+
try {
|
|
656
|
+
await spawnDetachedWithInput('xclip', ['-selection','clipboard','-loops','0'], value, { label: 'clipboard write', timeout: 3000 });
|
|
657
|
+
await confirmClipboard(value, 'xclip');
|
|
658
|
+
return jsonResult({ action, length: value.length, backend: 'xclip' });
|
|
659
|
+
} catch (error) { failures.push(`xclip: ${error instanceof Error ? error.message : String(error)}`); }
|
|
660
|
+
}
|
|
661
|
+
unavailable('Clipboard write', `${failures.join('; ') || 'install wl-clipboard or xclip'}`);
|
|
662
|
+
}
|
|
663
|
+
const failures = [];
|
|
664
|
+
if (commandExists('wl-paste')) {
|
|
665
|
+
const result = await runFile('wl-paste', ['--no-newline'], { label: 'clipboard read', allowFailure: true, timeout: 3000 });
|
|
666
|
+
if (result.code === 0) return text(result.stdout);
|
|
667
|
+
failures.push(`wl-paste: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`);
|
|
668
|
+
}
|
|
669
|
+
if (commandExists('xclip')) {
|
|
670
|
+
const result = await runFile('xclip', ['-selection','clipboard','-o'], { label: 'clipboard read', allowFailure: true, timeout: 3000 });
|
|
671
|
+
if (result.code === 0) return text(result.stdout);
|
|
672
|
+
failures.push(`xclip: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`);
|
|
673
|
+
}
|
|
674
|
+
unavailable('Clipboard read', `${failures.join('; ') || 'install wl-clipboard or xclip'}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export async function typeTextFocused(value) {
|
|
678
|
+
if (!commandExists('python3')) return null;
|
|
679
|
+
const script = String.raw`import gi,json,sys
|
|
680
|
+
gi.require_version("Atspi","2.0")
|
|
681
|
+
from gi.repository import Atspi
|
|
682
|
+
value=sys.argv[1]
|
|
683
|
+
desktop=Atspi.get_desktop(0)
|
|
684
|
+
active=[];showing=[]
|
|
685
|
+
for i in range(desktop.get_child_count()):
|
|
686
|
+
app=desktop.get_child_at_index(i)
|
|
687
|
+
try:
|
|
688
|
+
states=app.get_state_set()
|
|
689
|
+
if states.contains(Atspi.StateType.ACTIVE): active.append(app);continue
|
|
690
|
+
visible=states.contains(Atspi.StateType.SHOWING)
|
|
691
|
+
if not visible:
|
|
692
|
+
for j in range(app.get_child_count()):
|
|
693
|
+
child=app.get_child_at_index(j);child_states=child.get_state_set()
|
|
694
|
+
if child_states.contains(Atspi.StateType.ACTIVE):
|
|
695
|
+
active.append(app);visible=False;break
|
|
696
|
+
if child_states.contains(Atspi.StateType.SHOWING):visible=True
|
|
697
|
+
if visible:showing.append(app)
|
|
698
|
+
except Exception: pass
|
|
699
|
+
queue=list(active or showing)
|
|
700
|
+
seen=0;target=None
|
|
701
|
+
while queue and seen<12000:
|
|
702
|
+
a=queue.pop(0);seen+=1
|
|
703
|
+
try:
|
|
704
|
+
states=a.get_state_set()
|
|
705
|
+
if states.contains(Atspi.StateType.FOCUSED) and states.contains(Atspi.StateType.EDITABLE) and a.get_editable_text_iface() is not None:
|
|
706
|
+
target=a;break
|
|
707
|
+
for i in range(a.get_child_count()): queue.append(a.get_child_at_index(i))
|
|
708
|
+
except Exception: pass
|
|
709
|
+
if target is None:
|
|
710
|
+
print(json.dumps({"ok":False,"reason":"no focused editable accessibility element"}));sys.exit(3)
|
|
711
|
+
try:
|
|
712
|
+
pos=max(0,int(target.get_caret_offset()))
|
|
713
|
+
selections=target.get_text_selections() or []
|
|
714
|
+
if selections:
|
|
715
|
+
selection=selections[0]
|
|
716
|
+
if selection.start_object==target and selection.end_object==target and selection.end_offset>selection.start_offset:
|
|
717
|
+
if not target.delete_text(selection.start_offset,selection.end_offset): raise RuntimeError("could not replace current selection")
|
|
718
|
+
pos=int(selection.start_offset)
|
|
719
|
+
ok=bool(target.insert_text(pos,value,len(value)))
|
|
720
|
+
print(json.dumps({"ok":ok,"role":target.get_role_name(),"name":target.get_name() or "","pid":target.get_process_id(),"inserted":len(value)}))
|
|
721
|
+
sys.exit(0 if ok else 4)
|
|
722
|
+
except Exception as exc:
|
|
723
|
+
print(json.dumps({"ok":False,"reason":str(exc)}));sys.exit(5)`;
|
|
724
|
+
const result = await runFile('python3', ['-c', script, String(value)], {
|
|
725
|
+
label: 'AT-SPI focused text input',
|
|
726
|
+
allowFailure: true,
|
|
727
|
+
timeout: 5000,
|
|
728
|
+
});
|
|
729
|
+
if (result.code !== 0) return null;
|
|
730
|
+
try {
|
|
731
|
+
const payload = JSON.parse(result.stdout || '{}');
|
|
732
|
+
return payload.ok ? payload : null;
|
|
733
|
+
} catch { return null; }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function normalizeShortcut(shortcut) {
|
|
737
|
+
return shortcut.toLowerCase().replace(/cmd|command|meta/g, 'super').replace(/control/g, 'ctrl');
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function inputBackend(args = {}) {
|
|
741
|
+
return requireEnum(args.backend || 'auto', 'backend', ['auto','x11','portal']);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function portalPermissionHint(feature) {
|
|
745
|
+
unavailable(feature, 'one-time XDG RemoteDesktop permission has not been granted; retry with backend=portal to request it');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export async function keyboard(args = {}) {
|
|
749
|
+
const shortcut = optionalString(args.shortcut) || (Array.isArray(args.keys) ? args.keys.join('+') : optionalString(args.key));
|
|
750
|
+
if (!shortcut) throw new Error('shortcut or key is required');
|
|
751
|
+
const backend = inputBackend(args);
|
|
752
|
+
const wtypeArgs = () => {
|
|
753
|
+
const tokens = normalizeShortcut(shortcut).split('+').filter(Boolean);
|
|
754
|
+
const modifiers = tokens.slice(0,-1).map(v => ({ctrl:'ctrl',alt:'alt',shift:'shift',super:'logo'}[v])).filter(Boolean);
|
|
755
|
+
const key = tokens.at(-1);
|
|
756
|
+
const argv = [];
|
|
757
|
+
for (const mod of modifiers) argv.push('-M', mod);
|
|
758
|
+
argv.push('-P', key, '-p', key);
|
|
759
|
+
for (const mod of [...modifiers].reverse()) argv.push('-m', mod);
|
|
760
|
+
return argv;
|
|
761
|
+
};
|
|
762
|
+
if (isWaylandSession()) {
|
|
763
|
+
if (backend !== 'portal' && commandExists('wtype')) {
|
|
764
|
+
const result = await runFile('wtype', wtypeArgs(), { label: 'keyboard', allowFailure: true, timeout: 5000 });
|
|
765
|
+
if (result.code === 0) return text(`Sent ${shortcut} via wtype.`);
|
|
766
|
+
}
|
|
767
|
+
if (backend === 'portal') {
|
|
768
|
+
if (!waylandPortalCandidate()) unavailable('Wayland keyboard input', 'no XDG RemoteDesktop portal is available');
|
|
769
|
+
await portalShortcut(shortcut, { timeoutMs: 120_000 });
|
|
770
|
+
return text(`Sent ${shortcut} via the Wayland Remote Desktop portal.`);
|
|
771
|
+
}
|
|
772
|
+
if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
|
|
773
|
+
try {
|
|
774
|
+
await portalShortcut(shortcut, { timeoutMs: 2500 });
|
|
775
|
+
return text(`Sent ${shortcut} via the Wayland Remote Desktop portal.`);
|
|
776
|
+
} catch {}
|
|
777
|
+
}
|
|
778
|
+
if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland keyboard input');
|
|
779
|
+
unavailable('Wayland keyboard input', backend === 'x11'
|
|
780
|
+
? 'XTEST/xdotool keyboard events are not reliably delivered through GNOME Wayland; use backend=portal or semantic accessibility input'
|
|
781
|
+
: 'no working keyboard backend is available');
|
|
782
|
+
}
|
|
783
|
+
if (commandExists('xdotool')) {
|
|
784
|
+
const windowId = await inputWindowId(args);
|
|
785
|
+
if (windowId) await focusInputWindow(windowId);
|
|
786
|
+
await runFile('xdotool', ['key','--clearmodifiers',...(windowId ? ['--window',windowId] : []),normalizeShortcut(shortcut)], { label: 'keyboard' });
|
|
787
|
+
return text(`Sent ${shortcut}${windowId ? ` to ${windowId}` : ''}.`);
|
|
788
|
+
}
|
|
789
|
+
if (commandExists('wtype')) {
|
|
790
|
+
await runFile('wtype', wtypeArgs(), { label: 'keyboard' });
|
|
791
|
+
return text(`Sent ${shortcut}.`);
|
|
792
|
+
}
|
|
793
|
+
unavailable('Keyboard input', 'install xdotool or wtype');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export async function typeTextKeys(value, args = {}) {
|
|
797
|
+
const delay = clamp(args.delay_ms, 1, 0, 1000);
|
|
798
|
+
const backend = inputBackend(args);
|
|
799
|
+
if (isWaylandSession()) {
|
|
800
|
+
if (backend !== 'portal' && commandExists('wtype')) {
|
|
801
|
+
const result = await runFile('wtype', ['-d', String(delay), '--', String(value)], { label: 'type text', timeout: 30_000, allowFailure: true });
|
|
802
|
+
if (result.code === 0) return { ...result, backend: 'wtype' };
|
|
803
|
+
}
|
|
804
|
+
if (backend === 'portal') {
|
|
805
|
+
if (!waylandPortalCandidate()) unavailable('Wayland text input', 'no XDG RemoteDesktop portal is available');
|
|
806
|
+
await portalTypeText(String(value), { delayMs: delay, timeoutMs: 120_000 });
|
|
807
|
+
return { stdout: '', stderr: '', code: 0, backend: 'xdg-desktop-portal' };
|
|
808
|
+
}
|
|
809
|
+
if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
|
|
810
|
+
try {
|
|
811
|
+
await portalTypeText(String(value), { delayMs: delay, timeoutMs: 2500 });
|
|
812
|
+
return { stdout: '', stderr: '', code: 0, backend: 'xdg-desktop-portal' };
|
|
813
|
+
} catch {}
|
|
814
|
+
}
|
|
815
|
+
if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland text input');
|
|
816
|
+
unavailable('Wayland text input', backend === 'x11'
|
|
817
|
+
? 'XTEST/xdotool text input is not reliably delivered through GNOME Wayland; use semantic accessibility typing or backend=portal'
|
|
818
|
+
: 'no working text input backend is available');
|
|
819
|
+
}
|
|
820
|
+
if (commandExists('xdotool')) {
|
|
821
|
+
const windowId = await inputWindowId(args);
|
|
822
|
+
if (windowId) await focusInputWindow(windowId);
|
|
823
|
+
const result = await runFile('xdotool', ['type','--clearmodifiers','--delay',String(delay),...(windowId ? ['--window',windowId] : []),String(value)], { label: 'type text', timeout: 30_000 });
|
|
824
|
+
return { ...result, backend: windowId ? 'xdotool-targeted' : 'xdotool' };
|
|
825
|
+
}
|
|
826
|
+
if (commandExists('wtype')) return runFile('wtype', ['-d', String(delay), '--', String(value)], { label: 'type text', timeout: 30_000 });
|
|
827
|
+
unavailable('Text input', 'install wtype or xdotool');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function buttonNumber(button) {
|
|
831
|
+
return String(button || 'left').toLowerCase() === 'right' ? '3' : String(button || 'left').toLowerCase() === 'middle' ? '2' : '1';
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
let portalPointerState = null;
|
|
835
|
+
|
|
836
|
+
async function portalDesktopAnchor() {
|
|
837
|
+
const rows = JSON.parse((await displayInventory()).content?.[0]?.text || '[]');
|
|
838
|
+
const displays = Array.isArray(rows) ? rows.filter(row =>
|
|
839
|
+
Number.isFinite(Number(row.x)) && Number.isFinite(Number(row.y))
|
|
840
|
+
&& Number.isFinite(Number(row.width)) && Number.isFinite(Number(row.height))
|
|
841
|
+
) : [];
|
|
842
|
+
if (!displays.length) return { x:0, y:0 };
|
|
843
|
+
const left = Math.min(...displays.map(row => Number(row.x)));
|
|
844
|
+
const top = Math.min(...displays.map(row => Number(row.y)));
|
|
845
|
+
return { x:left, y:top };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function calibratePortalPointer(options = {}) {
|
|
849
|
+
const motionOptions = { ...options };
|
|
850
|
+
delete motionOptions.recalibrate;
|
|
851
|
+
await portalPointerMotion(-100000, -100000, motionOptions);
|
|
852
|
+
portalPointerState = await portalDesktopAnchor();
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function portalMoveTo(x, y, options = {}) {
|
|
856
|
+
const targetX = Math.trunc(Number(x));
|
|
857
|
+
const targetY = Math.trunc(Number(y));
|
|
858
|
+
if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) throw new Error('x and y must be finite coordinates');
|
|
859
|
+
if (options.recalibrate || !portalPointerState) await calibratePortalPointer(options);
|
|
860
|
+
const motionOptions = { ...options };
|
|
861
|
+
delete motionOptions.recalibrate;
|
|
862
|
+
await portalPointerMotion(targetX - portalPointerState.x, targetY - portalPointerState.y, motionOptions);
|
|
863
|
+
portalPointerState = { x:targetX, y:targetY };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export async function pointer(args = {}) {
|
|
867
|
+
const action = requireEnum(args.action, 'action', ['move','click','double_click','right_click','down','up']);
|
|
868
|
+
const x = Number(args.x), y = Number(args.y);
|
|
869
|
+
const buttonName = action === 'right_click' ? 'right' : String(args.button || 'left').toLowerCase();
|
|
870
|
+
const backend = inputBackend(args);
|
|
871
|
+
|
|
872
|
+
const runXdotool = async () => {
|
|
873
|
+
if (!commandExists('xdotool')) return false;
|
|
874
|
+
const argv = [];
|
|
875
|
+
if (Number.isFinite(x) && Number.isFinite(y)) argv.push('mousemove', String(Math.trunc(x)), String(Math.trunc(y)));
|
|
876
|
+
else if (action === 'move') throw new Error('x and y are required for pointer move');
|
|
877
|
+
const button = buttonNumber(buttonName);
|
|
878
|
+
if (action === 'click' || action === 'right_click') argv.push('click', button);
|
|
879
|
+
else if (action === 'double_click') argv.push('click','--repeat','2','--delay','70',button);
|
|
880
|
+
else if (action === 'down') argv.push('mousedown', button);
|
|
881
|
+
else if (action === 'up') argv.push('mouseup', button);
|
|
882
|
+
await runFile('xdotool', argv, { label: 'pointer' });
|
|
883
|
+
return true;
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const runPortal = async timeoutMs => {
|
|
887
|
+
const portalOptions = { timeoutMs };
|
|
888
|
+
if (Number.isFinite(x) && Number.isFinite(y)) await portalMoveTo(x, y, portalOptions);
|
|
889
|
+
else if (action === 'move') throw new Error('x and y are required for pointer move');
|
|
890
|
+
const click = async () => {
|
|
891
|
+
await portalPointerButton(buttonName, true, portalOptions);
|
|
892
|
+
await portalPointerButton(buttonName, false, portalOptions);
|
|
893
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
894
|
+
};
|
|
895
|
+
if (action === 'click' || action === 'right_click') await click();
|
|
896
|
+
else if (action === 'double_click') {
|
|
897
|
+
await click();
|
|
898
|
+
await new Promise(resolve => setTimeout(resolve, 70));
|
|
899
|
+
await click();
|
|
900
|
+
} else if (action === 'down') await portalPointerButton(buttonName, true, portalOptions);
|
|
901
|
+
else if (action === 'up') {
|
|
902
|
+
await portalPointerButton(buttonName, false, portalOptions);
|
|
903
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
if (isWaylandSession()) {
|
|
908
|
+
if (backend === 'portal') {
|
|
909
|
+
if (!waylandPortalCandidate()) unavailable('Wayland pointer control', 'no XDG RemoteDesktop portal is available');
|
|
910
|
+
await runPortal(120_000);
|
|
911
|
+
return text(`Pointer ${action} completed via the Wayland Remote Desktop portal.`);
|
|
912
|
+
}
|
|
913
|
+
if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
|
|
914
|
+
try {
|
|
915
|
+
await runPortal(2500);
|
|
916
|
+
return text(`Pointer ${action} completed via the Wayland Remote Desktop portal.`);
|
|
917
|
+
} catch {}
|
|
918
|
+
}
|
|
919
|
+
if (backend !== 'portal' && await runXdotool()) return text(`Pointer ${action} completed via XWayland xdotool.`);
|
|
920
|
+
if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland pointer control');
|
|
921
|
+
unavailable('Wayland pointer control', backend === 'x11' ? 'xdotool is unavailable' : 'no working pointer backend is available');
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (await runXdotool()) return text(`Pointer ${action} completed.`);
|
|
925
|
+
unavailable('Pointer control', 'install xdotool');
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export async function dragDrop(args = {}) {
|
|
929
|
+
const backend = inputBackend(args);
|
|
930
|
+
const runXdotool = async () => {
|
|
931
|
+
if (!commandExists('xdotool')) return false;
|
|
932
|
+
const button = buttonNumber(args.button);
|
|
933
|
+
await runFile('xdotool', [
|
|
934
|
+
'mousemove',String(Math.trunc(args.from_x)),String(Math.trunc(args.from_y)),
|
|
935
|
+
'mousedown',button,
|
|
936
|
+
'mousemove','--sync',String(Math.trunc(args.to_x)),String(Math.trunc(args.to_y)),
|
|
937
|
+
'mouseup',button,
|
|
938
|
+
], { label: 'drag and drop' });
|
|
939
|
+
return true;
|
|
940
|
+
};
|
|
941
|
+
const runPortal = async timeoutMs => {
|
|
942
|
+
const button = String(args.button || 'left').toLowerCase();
|
|
943
|
+
const portalOptions = { timeoutMs };
|
|
944
|
+
await portalMoveTo(Number(args.from_x), Number(args.from_y), portalOptions);
|
|
945
|
+
await portalPointerButton(button, true, portalOptions);
|
|
946
|
+
await new Promise(resolve => setTimeout(resolve, clamp(args.hold_ms, 120, 0, 5000)));
|
|
947
|
+
await portalMoveTo(Number(args.to_x), Number(args.to_y), portalOptions);
|
|
948
|
+
await new Promise(resolve => setTimeout(resolve, clamp(args.duration_ms, 120, 0, 5000)));
|
|
949
|
+
await portalPointerButton(button, false, portalOptions);
|
|
950
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
951
|
+
};
|
|
952
|
+
if (isWaylandSession()) {
|
|
953
|
+
if (backend === 'portal') {
|
|
954
|
+
if (!waylandPortalCandidate()) unavailable('Wayland drag and drop', 'no XDG RemoteDesktop portal is available');
|
|
955
|
+
await runPortal(120_000);
|
|
956
|
+
return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xdg-desktop-portal' });
|
|
957
|
+
}
|
|
958
|
+
if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
|
|
959
|
+
try {
|
|
960
|
+
await runPortal(2500);
|
|
961
|
+
return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xdg-desktop-portal' });
|
|
962
|
+
} catch {}
|
|
963
|
+
}
|
|
964
|
+
if (backend !== 'portal' && await runXdotool()) return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xwayland-xdotool' });
|
|
965
|
+
if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland drag and drop');
|
|
966
|
+
unavailable('Wayland drag and drop', backend === 'x11' ? 'xdotool is unavailable' : 'no working drag backend is available');
|
|
967
|
+
}
|
|
968
|
+
if (await runXdotool()) return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xdotool' });
|
|
969
|
+
unavailable('Drag and drop', 'install xdotool');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export async function scroll(args = {}) {
|
|
973
|
+
const dx = Number(args.delta_x || 0), dy = Number(args.delta_y ?? args.delta ?? 0);
|
|
974
|
+
const backend = inputBackend(args);
|
|
975
|
+
const runXdotool = async () => {
|
|
976
|
+
if (!commandExists('xdotool')) return false;
|
|
977
|
+
if (dy) {
|
|
978
|
+
const button = dy < 0 ? '4' : '5';
|
|
979
|
+
const repeats = Math.max(1, Math.min(50, Math.ceil(Math.abs(dy) / 120)));
|
|
980
|
+
await runFile('xdotool', ['click','--repeat',String(repeats),button], { label: 'vertical scroll' });
|
|
981
|
+
}
|
|
982
|
+
if (dx) {
|
|
983
|
+
const button = dx < 0 ? '6' : '7';
|
|
984
|
+
const repeats = Math.max(1, Math.min(50, Math.ceil(Math.abs(dx) / 120)));
|
|
985
|
+
await runFile('xdotool', ['click','--repeat',String(repeats),button], { label: 'horizontal scroll' });
|
|
986
|
+
}
|
|
987
|
+
return true;
|
|
988
|
+
};
|
|
989
|
+
if (isWaylandSession()) {
|
|
990
|
+
if (backend === 'portal') {
|
|
991
|
+
if (!waylandPortalCandidate()) unavailable('Wayland scroll', 'no XDG RemoteDesktop portal is available');
|
|
992
|
+
await portalScroll(dx, dy, { timeoutMs: 120_000 });
|
|
993
|
+
return jsonResult({ delta_x: dx, delta_y: dy, backend:'xdg-desktop-portal' });
|
|
994
|
+
}
|
|
995
|
+
if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
|
|
996
|
+
try {
|
|
997
|
+
await portalScroll(dx, dy, { timeoutMs: 2500 });
|
|
998
|
+
return jsonResult({ delta_x: dx, delta_y: dy, backend:'xdg-desktop-portal' });
|
|
999
|
+
} catch {}
|
|
1000
|
+
}
|
|
1001
|
+
if (backend !== 'portal' && await runXdotool()) return jsonResult({ delta_x: dx, delta_y: dy, backend:'xwayland-xdotool' });
|
|
1002
|
+
if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland scroll');
|
|
1003
|
+
unavailable('Wayland scroll', backend === 'x11' ? 'xdotool is unavailable' : 'no working scroll backend is available');
|
|
1004
|
+
}
|
|
1005
|
+
if (await runXdotool()) return jsonResult({ delta_x: dx, delta_y: dy, backend:'xdotool' });
|
|
1006
|
+
unavailable('Scroll', 'install xdotool');
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export async function cursorPosition() {
|
|
1010
|
+
if (!commandExists('xdotool')) unavailable('Cursor position', 'install xdotool');
|
|
1011
|
+
const result = await runFile('xdotool', ['getmouselocation','--shell'], { label:'cursor position', allowFailure:true, timeout:2000 });
|
|
1012
|
+
const x = Number(result.stdout.match(/^X=(-?\d+)$/m)?.[1]);
|
|
1013
|
+
const y = Number(result.stdout.match(/^Y=(-?\d+)$/m)?.[1]);
|
|
1014
|
+
const screen = Number(result.stdout.match(/^SCREEN=(-?\d+)$/m)?.[1]);
|
|
1015
|
+
if (result.code !== 0 || !Number.isFinite(x) || !Number.isFinite(y)) unavailable('Cursor position', (result.stderr || result.stdout || 'xdotool failed').trim());
|
|
1016
|
+
return jsonResult({ x, y, screen: Number.isFinite(screen) ? screen : null });
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function unwrapDbus(value) {
|
|
1020
|
+
if (value && typeof value === 'object' && Object.hasOwn(value, 'value')) return unwrapDbus(value.value);
|
|
1021
|
+
if (Array.isArray(value)) return value.map(unwrapDbus);
|
|
1022
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrapDbus(item)]));
|
|
1023
|
+
return value;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function mutterDisplayInventory() {
|
|
1027
|
+
if (!isWaylandSession()) return null;
|
|
1028
|
+
let dbus;
|
|
1029
|
+
try { dbus = await import('@jellybrick/dbus-next'); } catch { return null; }
|
|
1030
|
+
const bus = dbus.sessionBus();
|
|
1031
|
+
try {
|
|
1032
|
+
const object = await bus.getProxyObject('org.gnome.Mutter.DisplayConfig', '/org/gnome/Mutter/DisplayConfig');
|
|
1033
|
+
const iface = object.getInterface('org.gnome.Mutter.DisplayConfig');
|
|
1034
|
+
const state = unwrapDbus(await iface.GetCurrentState());
|
|
1035
|
+
if (!Array.isArray(state) || state.length < 3) return null;
|
|
1036
|
+
const monitors = Array.isArray(state[1]) ? state[1] : [];
|
|
1037
|
+
const logical = Array.isArray(state[2]) ? state[2] : [];
|
|
1038
|
+
const monitorByKey = new Map();
|
|
1039
|
+
for (const monitor of monitors) {
|
|
1040
|
+
const spec = Array.isArray(monitor?.[0]) ? monitor[0] : [];
|
|
1041
|
+
const modes = Array.isArray(monitor?.[1]) ? monitor[1] : [];
|
|
1042
|
+
const props = monitor?.[2] && typeof monitor[2] === 'object' ? monitor[2] : {};
|
|
1043
|
+
const current = modes.find(mode => mode?.[6]?.['is-current'] === true)
|
|
1044
|
+
|| modes.find(mode => mode?.[6]?.['is-preferred'] === true)
|
|
1045
|
+
|| modes[0];
|
|
1046
|
+
monitorByKey.set(JSON.stringify(spec), {
|
|
1047
|
+
connector: String(spec[0] || ''),
|
|
1048
|
+
vendor: String(spec[1] || ''),
|
|
1049
|
+
product: String(spec[2] || ''),
|
|
1050
|
+
serial: String(spec[3] || ''),
|
|
1051
|
+
display_name: String(props['display-name'] || spec[0] || 'Display'),
|
|
1052
|
+
builtin: Boolean(props['is-builtin']),
|
|
1053
|
+
mode: current ? {
|
|
1054
|
+
width: Number(current[1]) || null,
|
|
1055
|
+
height: Number(current[2]) || null,
|
|
1056
|
+
refresh_hz: Number(current[3]) || null,
|
|
1057
|
+
} : null,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const rows = [];
|
|
1061
|
+
for (const item of logical) {
|
|
1062
|
+
if (!Array.isArray(item) || item.length < 6) continue;
|
|
1063
|
+
const x = Number(item[0]), y = Number(item[1]), scale = Number(item[2]) || 1;
|
|
1064
|
+
const transform = Number(item[3]) || 0;
|
|
1065
|
+
const primary = Boolean(item[4]);
|
|
1066
|
+
const specs = Array.isArray(item[5]) ? item[5] : [];
|
|
1067
|
+
for (const spec of specs) {
|
|
1068
|
+
const details = monitorByKey.get(JSON.stringify(spec)) || {
|
|
1069
|
+
connector: String(spec?.[0] || ''),
|
|
1070
|
+
vendor: String(spec?.[1] || ''),
|
|
1071
|
+
product: String(spec?.[2] || ''),
|
|
1072
|
+
serial: String(spec?.[3] || ''),
|
|
1073
|
+
display_name: String(spec?.[0] || 'Display'),
|
|
1074
|
+
mode: null,
|
|
1075
|
+
};
|
|
1076
|
+
const pixelWidth = Number(details.mode?.width) || null;
|
|
1077
|
+
const pixelHeight = Number(details.mode?.height) || null;
|
|
1078
|
+
const rotated = [1,3,5,7].includes(transform);
|
|
1079
|
+
rows.push({
|
|
1080
|
+
name: details.connector || details.display_name,
|
|
1081
|
+
display_name: details.display_name,
|
|
1082
|
+
primary,
|
|
1083
|
+
x: Number.isFinite(x) ? x : null,
|
|
1084
|
+
y: Number.isFinite(y) ? y : null,
|
|
1085
|
+
width: pixelWidth == null ? null : Math.round((rotated ? pixelHeight : pixelWidth) / scale),
|
|
1086
|
+
height: pixelHeight == null ? null : Math.round((rotated ? pixelWidth : pixelHeight) / scale),
|
|
1087
|
+
pixel_width: pixelWidth,
|
|
1088
|
+
pixel_height: pixelHeight,
|
|
1089
|
+
scale,
|
|
1090
|
+
refresh_hz: details.mode?.refresh_hz ?? null,
|
|
1091
|
+
transform,
|
|
1092
|
+
builtin: Boolean(details.builtin),
|
|
1093
|
+
vendor: details.vendor || null,
|
|
1094
|
+
product: details.product || null,
|
|
1095
|
+
serial: details.serial || null,
|
|
1096
|
+
backend: 'mutter',
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return rows.length ? rows : null;
|
|
1101
|
+
} catch {
|
|
1102
|
+
return null;
|
|
1103
|
+
} finally {
|
|
1104
|
+
try { bus.disconnect(); } catch {}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export async function displayInventory() {
|
|
1109
|
+
const mutter = await mutterDisplayInventory();
|
|
1110
|
+
if (mutter) return jsonResult(mutter);
|
|
1111
|
+
if (!commandExists('xrandr')) unavailable('Display inventory', 'GNOME Mutter DisplayConfig or xrandr is required');
|
|
1112
|
+
const { stdout } = await runFile('xrandr', ['--query'], { label: 'display inventory' });
|
|
1113
|
+
let scale = Number(process.env.GDK_SCALE || process.env.QT_SCALE_FACTOR || 0);
|
|
1114
|
+
if (!Number.isFinite(scale) || scale <= 0) scale = 0;
|
|
1115
|
+
if (!scale && commandExists('gsettings')) {
|
|
1116
|
+
const probe = await runFile('gsettings', ['get','org.gnome.desktop.interface','scaling-factor'], { label:'display scale', allowFailure:true, timeout:1500 });
|
|
1117
|
+
const parsed = Number(String(probe.stdout || '').match(/(\d+(?:\.\d+)?)/)?.[1]);
|
|
1118
|
+
if (Number.isFinite(parsed) && parsed > 0) scale = parsed;
|
|
1119
|
+
}
|
|
1120
|
+
if (!scale) scale = 1;
|
|
1121
|
+
const rows = [];
|
|
1122
|
+
for (const line of stdout.split('\n')) {
|
|
1123
|
+
const match = line.match(/^(\S+) connected( primary)?(?: (\d+)x(\d+)\+(-?\d+)\+(-?\d+))?/);
|
|
1124
|
+
if (match) rows.push({ name:match[1], display_name:match[1], primary:Boolean(match[2]), width:match[3]?Number(match[3]):null, height:match[4]?Number(match[4]):null, pixel_width:match[3]?Number(match[3]):null, pixel_height:match[4]?Number(match[4]):null, x:match[5]?Number(match[5]):null, y:match[6]?Number(match[6]):null, scale, backend:'xrandr' });
|
|
1125
|
+
}
|
|
1126
|
+
return jsonResult(rows);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
export async function screenshotRegion(args = {}) {
|
|
1130
|
+
const x=Math.trunc(Number(args.x)), y=Math.trunc(Number(args.y)), width=Math.trunc(Number(args.width)), height=Math.trunc(Number(args.height));
|
|
1131
|
+
if (![x,y,width,height].every(Number.isFinite) || width<=0 || height<=0) throw new Error('x, y, width and height are required; width/height must be positive');
|
|
1132
|
+
if (width > 8192 || height > 8192 || width * height > 16_777_216) throw new Error('screenshot region is too large; width/height must be <= 8192 and area <= 16 megapixels');
|
|
1133
|
+
const dir = await tempDir('remcp-region-');
|
|
1134
|
+
const target = path.join(dir, 'region.png');
|
|
1135
|
+
const full = path.join(dir, 'full.png');
|
|
1136
|
+
try {
|
|
1137
|
+
if (commandExists('grim')) {
|
|
1138
|
+
await runFile('grim', ['-g',`${x},${y} ${width}x${height}`,target], { label:'screenshot region' });
|
|
1139
|
+
} else if (isWaylandSession() && commandExists('ffmpeg')) {
|
|
1140
|
+
await capturePortalScreenshot(full);
|
|
1141
|
+
await runFile('ffmpeg', ['-hide_banner','-loglevel','error','-y','-i',full,'-vf',`crop=${width}:${height}:${x}:${y}`,'-frames:v','1',target], { label:'crop portal screenshot', timeout:30_000, maxBuffer:4*1024*1024 });
|
|
1142
|
+
} else if (commandExists('import')) {
|
|
1143
|
+
await runFile('import', ['-window','root','-crop',`${width}x${height}+${x}+${y}`,target], { label:'screenshot region' });
|
|
1144
|
+
} else {
|
|
1145
|
+
unavailable('Region screenshots', isWaylandSession() ? 'the XDG Desktop Portal plus ffmpeg, or grim, is required' : 'install ImageMagick import');
|
|
1146
|
+
}
|
|
1147
|
+
const info = await stat(target);
|
|
1148
|
+
if (info.size > 4 * 1024 * 1024) throw new Error(`captured region is ${info.size} bytes; request a smaller region so it fits the inline image limit`);
|
|
1149
|
+
const data = await readFile(target);
|
|
1150
|
+
return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`}, image(data.toString('base64'),'image/png')]);
|
|
1151
|
+
} finally { await removeTemp(dir); }
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export async function notification(args = {}) {
|
|
1155
|
+
if (!commandExists('notify-send')) unavailable('Desktop notifications', 'install notify-send');
|
|
1156
|
+
await runFile('notify-send', [optionalString(args.title)||'ReMCP', String(args.message ?? args.body ?? '')], { label:'notification' });
|
|
1157
|
+
return text('Notification sent.');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
export async function launchApp(app, argv = [], options = {}) {
|
|
1161
|
+
const pid = spawnDetached(app, argv, { cwd:options.cwd || undefined });
|
|
1162
|
+
return `Launched ${app} (pid ${pid})${options.cwd ? ` in ${options.cwd}` : ''}.`;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
export async function openPath(target) {
|
|
1166
|
+
if (!commandExists('xdg-open')) unavailable('Open path', 'xdg-open is required');
|
|
1167
|
+
spawnDetached('xdg-open', [target]);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
export async function revealPath(target) {
|
|
1171
|
+
if (!commandExists('xdg-open')) unavailable('Reveal path', 'xdg-open is required');
|
|
1172
|
+
spawnDetached('xdg-open', [path.dirname(target)]);
|
|
1173
|
+
}
|