@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,485 @@
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 { focusInputWindow, inputWindowId, windowMatch } from './desktop-linux-windows.mjs';
25
+ import { focusExplicitWaylandInputTarget } from './desktop-linux-window-actions.mjs';
26
+ import { uiAction, uiSnapshot } from './desktop-linux-ui.mjs';
27
+ import { displayInventory } from './desktop-linux-displays.mjs';
28
+
29
+ export async function confirmClipboard(expected, backend, timeoutMs = 1500) {
30
+ const deadline = Date.now() + timeoutMs;
31
+ while (Date.now() < deadline) {
32
+ const result = backend === 'wl-copy'
33
+ ? await runFile('wl-paste', ['--no-newline'], { label: 'clipboard verify', allowFailure: true, timeout: 500 })
34
+ : await runFile('xclip', ['-selection','clipboard','-o'], { label: 'clipboard verify', allowFailure: true, timeout: 500 });
35
+ if (result.code === 0 && result.stdout === expected) return;
36
+ await new Promise(resolve => setTimeout(resolve, 25));
37
+ }
38
+ throw new Error(`${backend} did not publish the requested clipboard text within ${timeoutMs} ms`);
39
+ }
40
+
41
+ export async function clipboard(args = {}) {
42
+ const action = requireEnum(args.action, 'action', ['read','write','clear']);
43
+ if (action !== 'read') {
44
+ const value = action === 'clear' ? '' : String(args.text ?? args.value ?? '');
45
+ const failures = [];
46
+ if (commandExists('wl-copy')) {
47
+ try {
48
+ if (action === 'clear') await runFile('wl-copy', ['--clear'], { label: 'clipboard clear', timeout: 3000 });
49
+ else {
50
+ await spawnDetachedWithInput('wl-copy', [], value, { label: 'clipboard write', timeout: 3000 });
51
+ if (commandExists('wl-paste')) await confirmClipboard(value, 'wl-copy');
52
+ }
53
+ return jsonResult({ action, length: value.length, backend: 'wl-copy' });
54
+ } catch (error) { failures.push(`wl-copy: ${error instanceof Error ? error.message : String(error)}`); }
55
+ }
56
+ if (commandExists('xclip')) {
57
+ try {
58
+ await spawnDetachedWithInput('xclip', ['-selection','clipboard','-loops','0'], value, { label: 'clipboard write', timeout: 3000 });
59
+ await confirmClipboard(value, 'xclip');
60
+ return jsonResult({ action, length: value.length, backend: 'xclip' });
61
+ } catch (error) { failures.push(`xclip: ${error instanceof Error ? error.message : String(error)}`); }
62
+ }
63
+ unavailable('Clipboard write', `${failures.join('; ') || 'install wl-clipboard or xclip'}`);
64
+ }
65
+ const failures = [];
66
+ if (commandExists('wl-paste')) {
67
+ const result = await runFile('wl-paste', ['--no-newline'], { label: 'clipboard read', allowFailure: true, timeout: 3000 });
68
+ if (result.code === 0) return text(result.stdout);
69
+ failures.push(`wl-paste: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`);
70
+ }
71
+ if (commandExists('xclip')) {
72
+ const result = await runFile('xclip', ['-selection','clipboard','-o'], { label: 'clipboard read', allowFailure: true, timeout: 3000 });
73
+ if (result.code === 0) return text(result.stdout);
74
+ failures.push(`xclip: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`);
75
+ }
76
+ unavailable('Clipboard read', `${failures.join('; ') || 'install wl-clipboard or xclip'}`);
77
+ }
78
+
79
+ export async function typeTextWindowTarget(value, args = {}) {
80
+ const row = await windowMatch(args);
81
+ if (!row?.pid) return null;
82
+ const snapshot = await uiSnapshot({
83
+ pid:row.pid,
84
+ window_title:row.title || undefined,
85
+ max_nodes:2200,
86
+ max_depth:24,
87
+ });
88
+ let payload = null;
89
+ try { payload = JSON.parse(snapshot.content?.[0]?.text || '{}'); } catch {}
90
+ const nodes = Array.isArray(payload) ? payload : payload?.nodes || [];
91
+ const editableRoles = /(^|\b)(text|entry|textbox|edit|text field|search box|password text)(\b|$)/i;
92
+ const candidates = nodes.filter(node => node?.id && editableRoles.test(String(node.role || '')));
93
+ if (candidates.length !== 1) return null;
94
+
95
+ const target = candidates[0];
96
+ const result = await uiAction({ action:'set_value', id:target.id, value:String(value) });
97
+ let action = result.content?.[0]?.text || '';
98
+ try { action = JSON.parse(action); } catch {}
99
+ return {
100
+ id:target.id,
101
+ pid:row.pid,
102
+ app:row.app || target.app || '',
103
+ window:row.title || target.window || '',
104
+ role:target.role || '',
105
+ name:target.name || '',
106
+ action,
107
+ };
108
+ }
109
+
110
+ export async function typeTextFocused(value) {
111
+ if (!commandExists('python3')) return null;
112
+ const script = String.raw`import gi,json,sys
113
+ gi.require_version("Atspi","2.0")
114
+ from gi.repository import Atspi
115
+ value=sys.argv[1]
116
+ desktop=Atspi.get_desktop(0)
117
+ active=[];showing=[]
118
+ for i in range(desktop.get_child_count()):
119
+ app=desktop.get_child_at_index(i)
120
+ try:
121
+ states=app.get_state_set()
122
+ if states.contains(Atspi.StateType.ACTIVE): active.append(app);continue
123
+ visible=states.contains(Atspi.StateType.SHOWING)
124
+ if not visible:
125
+ for j in range(app.get_child_count()):
126
+ child=app.get_child_at_index(j);child_states=child.get_state_set()
127
+ if child_states.contains(Atspi.StateType.ACTIVE):
128
+ active.append(app);visible=False;break
129
+ if child_states.contains(Atspi.StateType.SHOWING):visible=True
130
+ if visible:showing.append(app)
131
+ except Exception: pass
132
+ queue=list(active or showing)
133
+ seen=0;target=None
134
+ while queue and seen<12000:
135
+ a=queue.pop(0);seen+=1
136
+ try:
137
+ states=a.get_state_set()
138
+ if states.contains(Atspi.StateType.FOCUSED) and states.contains(Atspi.StateType.EDITABLE) and a.get_editable_text_iface() is not None:
139
+ target=a;break
140
+ for i in range(a.get_child_count()): queue.append(a.get_child_at_index(i))
141
+ except Exception: pass
142
+ if target is None:
143
+ print(json.dumps({"ok":False,"reason":"no focused editable accessibility element"}));sys.exit(3)
144
+ try:
145
+ pos=max(0,int(target.get_caret_offset()))
146
+ selections=target.get_text_selections() or []
147
+ if selections:
148
+ selection=selections[0]
149
+ if selection.start_object==target and selection.end_object==target and selection.end_offset>selection.start_offset:
150
+ if not target.delete_text(selection.start_offset,selection.end_offset): raise RuntimeError("could not replace current selection")
151
+ pos=int(selection.start_offset)
152
+ ok=bool(target.insert_text(pos,value,len(value)))
153
+ print(json.dumps({"ok":ok,"role":target.get_role_name(),"name":target.get_name() or "","pid":target.get_process_id(),"inserted":len(value)}))
154
+ sys.exit(0 if ok else 4)
155
+ except Exception as exc:
156
+ print(json.dumps({"ok":False,"reason":str(exc)}));sys.exit(5)`;
157
+ const result = await runFile('python3', ['-c', script, String(value)], {
158
+ label: 'AT-SPI focused text input',
159
+ allowFailure: true,
160
+ timeout: 5000,
161
+ });
162
+ if (result.code !== 0) return null;
163
+ try {
164
+ const payload = JSON.parse(result.stdout || '{}');
165
+ return payload.ok ? payload : null;
166
+ } catch { return null; }
167
+ }
168
+
169
+ export function normalizeShortcut(shortcut) {
170
+ return shortcut.toLowerCase().replace(/cmd|command|meta/g, 'super').replace(/control/g, 'ctrl');
171
+ }
172
+
173
+ export function inputBackend(args = {}) {
174
+ return requireEnum(args.backend || 'auto', 'backend', ['auto','x11','portal']);
175
+ }
176
+
177
+ export function portalPermissionHint(feature) {
178
+ unavailable(feature, 'one-time XDG RemoteDesktop permission has not been granted; retry with backend=portal to request it');
179
+ }
180
+
181
+ export async function keyboard(args = {}) {
182
+ const shortcut = optionalString(args.shortcut) || (Array.isArray(args.keys) ? args.keys.join('+') : optionalString(args.key));
183
+ if (!shortcut) throw new Error('shortcut or key is required');
184
+ const backend = inputBackend(args);
185
+ const wtypeArgs = () => {
186
+ const tokens = normalizeShortcut(shortcut).split('+').filter(Boolean);
187
+ const modifiers = tokens.slice(0,-1).map(v => ({ctrl:'ctrl',alt:'alt',shift:'shift',super:'logo'}[v])).filter(Boolean);
188
+ const key = tokens.at(-1);
189
+ const argv = [];
190
+ for (const mod of modifiers) argv.push('-M', mod);
191
+ argv.push('-P', key, '-p', key);
192
+ for (const mod of [...modifiers].reverse()) argv.push('-m', mod);
193
+ return argv;
194
+ };
195
+ if (isWaylandSession()) {
196
+ await focusExplicitWaylandInputTarget(args);
197
+ if (backend !== 'portal' && commandExists('wtype')) {
198
+ const result = await runFile('wtype', wtypeArgs(), { label: 'keyboard', allowFailure: true, timeout: 5000 });
199
+ if (result.code === 0) return text(`Sent ${shortcut} via wtype.`);
200
+ }
201
+ if (backend === 'portal') {
202
+ if (!waylandPortalCandidate()) unavailable('Wayland keyboard input', 'no XDG RemoteDesktop portal is available');
203
+ await portalShortcut(shortcut, { timeoutMs: 120_000 });
204
+ return text(`Sent ${shortcut} via the Wayland Remote Desktop portal.`);
205
+ }
206
+ if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
207
+ try {
208
+ await portalShortcut(shortcut, { timeoutMs: 2500 });
209
+ return text(`Sent ${shortcut} via the Wayland Remote Desktop portal.`);
210
+ } catch {}
211
+ }
212
+ if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland keyboard input');
213
+ unavailable('Wayland keyboard input', backend === 'x11'
214
+ ? 'XTEST/xdotool keyboard events are not reliably delivered through GNOME Wayland; use backend=portal or semantic accessibility input'
215
+ : 'no working keyboard backend is available');
216
+ }
217
+ if (commandExists('xdotool')) {
218
+ const windowId = await inputWindowId(args);
219
+ if (windowId) await focusInputWindow(windowId);
220
+ await runFile('xdotool', ['key','--clearmodifiers',...(windowId ? ['--window',windowId] : []),normalizeShortcut(shortcut)], { label: 'keyboard' });
221
+ return text(`Sent ${shortcut}${windowId ? ` to ${windowId}` : ''}.`);
222
+ }
223
+ if (commandExists('wtype')) {
224
+ await runFile('wtype', wtypeArgs(), { label: 'keyboard' });
225
+ return text(`Sent ${shortcut}.`);
226
+ }
227
+ unavailable('Keyboard input', 'install xdotool or wtype');
228
+ }
229
+
230
+ export async function typeTextKeys(value, args = {}) {
231
+ const delay = clamp(args.delay_ms, 1, 0, 1000);
232
+ const backend = inputBackend(args);
233
+ if (isWaylandSession()) {
234
+ await focusExplicitWaylandInputTarget(args);
235
+ if (backend !== 'portal' && commandExists('wtype')) {
236
+ const result = await runFile('wtype', ['-d', String(delay), '--', String(value)], { label: 'type text', timeout: 30_000, allowFailure: true });
237
+ if (result.code === 0) return { ...result, backend: 'wtype' };
238
+ }
239
+ if (backend === 'portal') {
240
+ if (!waylandPortalCandidate()) unavailable('Wayland text input', 'no XDG RemoteDesktop portal is available');
241
+ await portalTypeText(String(value), { delayMs: delay, timeoutMs: 120_000 });
242
+ return { stdout: '', stderr: '', code: 0, backend: 'xdg-desktop-portal' };
243
+ }
244
+ if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
245
+ try {
246
+ await portalTypeText(String(value), { delayMs: delay, timeoutMs: 2500 });
247
+ return { stdout: '', stderr: '', code: 0, backend: 'xdg-desktop-portal' };
248
+ } catch {}
249
+ }
250
+ if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland text input');
251
+ unavailable('Wayland text input', backend === 'x11'
252
+ ? 'XTEST/xdotool text input is not reliably delivered through GNOME Wayland; use semantic accessibility typing or backend=portal'
253
+ : 'no working text input backend is available');
254
+ }
255
+ if (commandExists('xdotool')) {
256
+ const windowId = await inputWindowId(args);
257
+ if (windowId) await focusInputWindow(windowId);
258
+ const result = await runFile('xdotool', ['type','--clearmodifiers','--delay',String(delay),...(windowId ? ['--window',windowId] : []),String(value)], { label: 'type text', timeout: 30_000 });
259
+ return { ...result, backend: windowId ? 'xdotool-targeted' : 'xdotool' };
260
+ }
261
+ if (commandExists('wtype')) return runFile('wtype', ['-d', String(delay), '--', String(value)], { label: 'type text', timeout: 30_000 });
262
+ unavailable('Text input', 'install wtype or xdotool');
263
+ }
264
+
265
+ export function buttonNumber(button) {
266
+ return String(button || 'left').toLowerCase() === 'right' ? '3' : String(button || 'left').toLowerCase() === 'middle' ? '2' : '1';
267
+ }
268
+
269
+ let portalPointerState = null;
270
+
271
+ export async function portalDesktopAnchor() {
272
+ const rows = JSON.parse((await displayInventory()).content?.[0]?.text || '[]');
273
+ const displays = Array.isArray(rows) ? rows.filter(row =>
274
+ Number.isFinite(Number(row.x)) && Number.isFinite(Number(row.y))
275
+ && Number.isFinite(Number(row.width)) && Number.isFinite(Number(row.height))
276
+ ) : [];
277
+ if (!displays.length) return { x:0, y:0 };
278
+ const left = Math.min(...displays.map(row => Number(row.x)));
279
+ const top = Math.min(...displays.map(row => Number(row.y)));
280
+ return { x:left, y:top };
281
+ }
282
+
283
+ export async function calibratePortalPointer(options = {}) {
284
+ const motionOptions = { ...options };
285
+ delete motionOptions.recalibrate;
286
+ await portalPointerMotion(-100000, -100000, motionOptions);
287
+ portalPointerState = await portalDesktopAnchor();
288
+ }
289
+
290
+ export async function portalMoveTo(x, y, options = {}) {
291
+ const targetX = Math.trunc(Number(x));
292
+ const targetY = Math.trunc(Number(y));
293
+ if (!Number.isFinite(targetX) || !Number.isFinite(targetY)) throw new Error('x and y must be finite coordinates');
294
+ const motionOptions = { ...options };
295
+ delete motionOptions.recalibrate;
296
+ try {
297
+ await portalPointerMotionAbsolute(targetX, targetY, motionOptions);
298
+ portalPointerState = { x:targetX, y:targetY };
299
+ return;
300
+ } catch (error) {
301
+ if (!/requires the EIS Remote Desktop backend/i.test(String(error?.message || error))) throw error;
302
+ }
303
+ // Legacy portal fallback: relative motion has no absolute position feedback,
304
+ // so recalibrate on every move instead of trusting cached coordinates.
305
+ await calibratePortalPointer(motionOptions);
306
+ await portalPointerMotion(targetX - portalPointerState.x, targetY - portalPointerState.y, motionOptions);
307
+ portalPointerState = { x:targetX, y:targetY };
308
+ }
309
+
310
+ export async function pointer(args = {}) {
311
+ const action = requireEnum(args.action, 'action', ['move','click','double_click','right_click','down','up']);
312
+ const x = Number(args.x), y = Number(args.y);
313
+ const buttonName = action === 'right_click' ? 'right' : String(args.button || 'left').toLowerCase();
314
+ const backend = inputBackend(args);
315
+
316
+ const runXdotool = async () => {
317
+ if (!commandExists('xdotool')) return false;
318
+ const argv = [];
319
+ if (Number.isFinite(x) && Number.isFinite(y)) argv.push('mousemove', String(Math.trunc(x)), String(Math.trunc(y)));
320
+ else if (action === 'move') throw new Error('x and y are required for pointer move');
321
+ const button = buttonNumber(buttonName);
322
+ if (action === 'click' || action === 'right_click') argv.push('click', button);
323
+ else if (action === 'double_click') argv.push('click','--repeat','2','--delay','70',button);
324
+ else if (action === 'down') argv.push('mousedown', button);
325
+ else if (action === 'up') argv.push('mouseup', button);
326
+ await runFile('xdotool', argv, { label: 'pointer' });
327
+ return true;
328
+ };
329
+
330
+ const runPortal = async timeoutMs => {
331
+ const portalOptions = { timeoutMs };
332
+ if (Number.isFinite(x) && Number.isFinite(y)) await portalMoveTo(x, y, portalOptions);
333
+ else if (action === 'move') throw new Error('x and y are required for pointer move');
334
+ const click = async () => {
335
+ await portalPointerButton(buttonName, true, portalOptions);
336
+ // A zero-duration press/release can be accepted by the portal transport but
337
+ // coalesced before the target toolkit observes a real click. Keep a short,
338
+ // human-scale hold so successful return means a deliverable button gesture.
339
+ await new Promise(resolve => setTimeout(resolve, 60));
340
+ await portalPointerButton(buttonName, false, portalOptions);
341
+ await new Promise(resolve => setTimeout(resolve, 40));
342
+ };
343
+ if (action === 'click' || action === 'right_click') await click();
344
+ else if (action === 'double_click') {
345
+ await click();
346
+ await new Promise(resolve => setTimeout(resolve, 70));
347
+ await click();
348
+ } else if (action === 'down') await portalPointerButton(buttonName, true, portalOptions);
349
+ else if (action === 'up') {
350
+ await portalPointerButton(buttonName, false, portalOptions);
351
+ await new Promise(resolve => setTimeout(resolve, 40));
352
+ }
353
+ };
354
+
355
+ if (isWaylandSession()) {
356
+ if (backend === 'portal') {
357
+ if (!waylandPortalCandidate()) unavailable('Wayland pointer control', 'no XDG RemoteDesktop portal is available');
358
+ await runPortal(120_000);
359
+ return text(`Pointer ${action} completed via the Wayland Remote Desktop portal.`);
360
+ }
361
+ if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
362
+ try {
363
+ await runPortal(2500);
364
+ return text(`Pointer ${action} completed via the Wayland Remote Desktop portal.`);
365
+ } catch {}
366
+ }
367
+ if (backend !== 'portal' && await runXdotool()) return text(`Pointer ${action} completed via XWayland xdotool.`);
368
+ if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland pointer control');
369
+ unavailable('Wayland pointer control', backend === 'x11' ? 'xdotool is unavailable' : 'no working pointer backend is available');
370
+ }
371
+
372
+ if (await runXdotool()) return text(`Pointer ${action} completed.`);
373
+ unavailable('Pointer control', 'install xdotool');
374
+ }
375
+
376
+ export async function dragDrop(args = {}) {
377
+ const backend = inputBackend(args);
378
+ const runXdotool = async () => {
379
+ if (!commandExists('xdotool')) return false;
380
+ const button = buttonNumber(args.button);
381
+ await runFile('xdotool', [
382
+ 'mousemove',String(Math.trunc(args.from_x)),String(Math.trunc(args.from_y)),
383
+ 'mousedown',button,
384
+ 'mousemove','--sync',String(Math.trunc(args.to_x)),String(Math.trunc(args.to_y)),
385
+ 'mouseup',button,
386
+ ], { label: 'drag and drop' });
387
+ return true;
388
+ };
389
+ const runPortal = async timeoutMs => {
390
+ const button = String(args.button || 'left').toLowerCase();
391
+ const portalOptions = { timeoutMs };
392
+ const fromX = Number(args.from_x), fromY = Number(args.from_y);
393
+ const toX = Number(args.to_x), toY = Number(args.to_y);
394
+ await portalMoveTo(fromX, fromY, portalOptions);
395
+ // Give the compositor/toolkit one frame to observe the source position before
396
+ // button-down. A transport ACK only proves that the portal accepted the event.
397
+ await new Promise(resolve => setTimeout(resolve, 60));
398
+ await portalPointerButton(button, true, portalOptions);
399
+ await new Promise(resolve => setTimeout(resolve, clamp(args.hold_ms, 120, 60, 5000)));
400
+
401
+ // Keep the pressed pointer on a single absolute-motion transition. Repeated
402
+ // absolute moves can cross EIS region/device contexts and lose the logical
403
+ // pressed state. The EIS helper converts the desktop target into region-local
404
+ // coordinates, so one target move is sufficient and preserves buttons=1.
405
+ await portalMoveTo(toX, toY, portalOptions);
406
+ await new Promise(resolve => setTimeout(resolve, clamp(args.duration_ms, 160, 80, 5000)));
407
+ // Reassert the final target immediately before release. On Mutter/XWayland this
408
+ // mirrors a real pointer-up gesture and ensures the release is delivered as a
409
+ // pointerup/mouseup at the dragged target instead of only clearing button state.
410
+ await portalMoveTo(toX, toY, portalOptions);
411
+ await new Promise(resolve => setTimeout(resolve, 40));
412
+ await portalPointerButton(button, false, portalOptions);
413
+ await new Promise(resolve => setTimeout(resolve, 80));
414
+ };
415
+ if (isWaylandSession()) {
416
+ if (backend === 'portal') {
417
+ if (!waylandPortalCandidate()) unavailable('Wayland drag and drop', 'no XDG RemoteDesktop portal is available');
418
+ await runPortal(120_000);
419
+ return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xdg-desktop-portal' });
420
+ }
421
+ if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
422
+ try {
423
+ await runPortal(2500);
424
+ return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xdg-desktop-portal' });
425
+ } catch {}
426
+ }
427
+ if (backend === 'x11') {
428
+ unavailable('Wayland drag and drop', 'XTEST/xdotool drag events are not reliably delivered through GNOME Wayland; use backend=portal');
429
+ }
430
+ if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland drag and drop');
431
+ unavailable('Wayland drag and drop', 'no working consent-backed drag backend is available');
432
+ }
433
+ if (await runXdotool()) return jsonResult({ from:[args.from_x,args.from_y], to:[args.to_x,args.to_y], backend:'xdotool' });
434
+ unavailable('Drag and drop', 'install xdotool');
435
+ }
436
+
437
+ export async function scroll(args = {}) {
438
+ const dx = Number(args.delta_x || 0), dy = Number(args.delta_y ?? args.delta ?? 0);
439
+ const backend = inputBackend(args);
440
+ const runXdotool = async () => {
441
+ if (!commandExists('xdotool')) return false;
442
+ if (dy) {
443
+ const button = dy < 0 ? '4' : '5';
444
+ const repeats = Math.max(1, Math.min(50, Math.ceil(Math.abs(dy) / 120)));
445
+ await runFile('xdotool', ['click','--repeat',String(repeats),button], { label: 'vertical scroll' });
446
+ }
447
+ if (dx) {
448
+ const button = dx < 0 ? '6' : '7';
449
+ const repeats = Math.max(1, Math.min(50, Math.ceil(Math.abs(dx) / 120)));
450
+ await runFile('xdotool', ['click','--repeat',String(repeats),button], { label: 'horizontal scroll' });
451
+ }
452
+ return true;
453
+ };
454
+ if (isWaylandSession()) {
455
+ if (backend === 'portal') {
456
+ if (!waylandPortalCandidate()) unavailable('Wayland scroll', 'no XDG RemoteDesktop portal is available');
457
+ await portalScroll(dx, dy, { timeoutMs: 120_000 });
458
+ return jsonResult({ delta_x: dx, delta_y: dy, backend:'xdg-desktop-portal' });
459
+ }
460
+ if (backend === 'auto' && hasWaylandRemoteDesktopGrant()) {
461
+ try {
462
+ await portalScroll(dx, dy, { timeoutMs: 2500 });
463
+ return jsonResult({ delta_x: dx, delta_y: dy, backend:'xdg-desktop-portal' });
464
+ } catch {}
465
+ }
466
+ if (backend !== 'portal' && await runXdotool()) return jsonResult({ delta_x: dx, delta_y: dy, backend:'xwayland-xdotool' });
467
+ if (backend === 'auto' && waylandPortalCandidate()) portalPermissionHint('Wayland scroll');
468
+ unavailable('Wayland scroll', backend === 'x11' ? 'xdotool is unavailable' : 'no working scroll backend is available');
469
+ }
470
+ if (await runXdotool()) return jsonResult({ delta_x: dx, delta_y: dy, backend:'xdotool' });
471
+ unavailable('Scroll', 'install xdotool');
472
+ }
473
+
474
+ export async function cursorPosition() {
475
+ if (isWaylandSession()) {
476
+ unavailable('Cursor position', 'native Wayland does not expose an authoritative global cursor position; XWayland xdotool coordinates may be stale');
477
+ }
478
+ if (!commandExists('xdotool')) unavailable('Cursor position', 'install xdotool');
479
+ const result = await runFile('xdotool', ['getmouselocation','--shell'], { label:'cursor position', allowFailure:true, timeout:2000 });
480
+ const x = Number(result.stdout.match(/^X=(-?\d+)$/m)?.[1]);
481
+ const y = Number(result.stdout.match(/^Y=(-?\d+)$/m)?.[1]);
482
+ const screen = Number(result.stdout.match(/^SCREEN=(-?\d+)$/m)?.[1]);
483
+ if (result.code !== 0 || !Number.isFinite(x) || !Number.isFinite(y)) unavailable('Cursor position', (result.stderr || result.stdout || 'xdotool failed').trim());
484
+ return jsonResult({ x, y, screen: Number.isFinite(screen) ? screen : null });
485
+ }
@@ -0,0 +1,105 @@
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 { displayInventory, pngDimensions, portalCropGeometry, regionScreenshotBackends } from './desktop-linux-displays.mjs';
25
+
26
+ export async function screenshotRegion(args = {}) {
27
+ 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));
28
+ 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');
29
+ 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');
30
+ const dir = await tempDir('remcp-region-');
31
+ const target = path.join(dir, 'region.png');
32
+ const full = path.join(dir, 'full.png');
33
+ let captured = { x, y, width, height };
34
+ let clipped = false;
35
+ let backend = '';
36
+ try {
37
+ const cropFullDesktop = async () => {
38
+ const inventory = await displayInventory().catch(() => null);
39
+ const displays = Array.isArray(inventory?.structuredContent?.data) ? inventory.structuredContent.data : [];
40
+ const bitmap = await pngDimensions(full);
41
+ const crop = portalCropGeometry({ x, y, width, height }, displays, bitmap);
42
+ captured = crop.visible;
43
+ clipped = crop.clipped;
44
+ await runFile('ffmpeg', ['-hide_banner','-loglevel','error','-y','-i',full,'-vf',`crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`,'-frames:v','1',target], { label:'crop desktop screenshot', timeout:30_000, maxBuffer:4*1024*1024 });
45
+ };
46
+
47
+ const candidates = regionScreenshotBackends();
48
+ if (!candidates.length) {
49
+ unavailable('Region screenshots', isWaylandSession()
50
+ ? 'install grim, or gnome-screenshot/ffmpeg, or provide the XDG Desktop Portal plus ffmpeg'
51
+ : 'install ImageMagick import');
52
+ }
53
+
54
+ let lastError = null;
55
+ for (const candidate of candidates) {
56
+ try {
57
+ if (candidate === 'grim') {
58
+ await runFile('grim', ['-g',`${x},${y} ${width}x${height}`,target], { label:'screenshot region' });
59
+ } else if (candidate === 'gnome-screenshot') {
60
+ await runFile('gnome-screenshot', ['-f',full], { label:'GNOME screenshot', timeout:10_000, maxBuffer:1024*1024 });
61
+ await cropFullDesktop();
62
+ } else if (candidate === 'portal') {
63
+ await capturePortalScreenshot(full);
64
+ await cropFullDesktop();
65
+ } else if (candidate === 'imagemagick') {
66
+ await runFile('import', ['-window','root','-crop',`${width}x${height}+${x}+${y}`,target], { label:'screenshot region' });
67
+ }
68
+ backend = candidate;
69
+ lastError = null;
70
+ break;
71
+ } catch (error) {
72
+ if (error?.code === 'PORTAL_CANCELLED') throw error;
73
+ lastError = error;
74
+ }
75
+ }
76
+ if (lastError) throw lastError;
77
+
78
+ const {data} = await readPrivateTempFile(target, 4 * 1024 * 1024);
79
+ const baseSummary = clipped
80
+ ? `Captured visible ${captured.width}x${captured.height} at ${captured.x},${captured.y}; clipped from requested ${width}x${height} at ${x},${y}.`
81
+ : `Captured ${width}x${height} at ${x},${y}.`;
82
+ return multi([{type:'text',text:`${baseSummary} Backend: ${backend}.`}, image(data.toString('base64'),'image/png')]);
83
+ } finally { await removeTemp(dir); }
84
+ }
85
+
86
+ export async function notification(args = {}) {
87
+ if (!commandExists('notify-send')) unavailable('Desktop notifications', 'install notify-send');
88
+ await runFile('notify-send', [optionalString(args.title)||'ReMCP', String(args.message ?? args.body ?? '')], { label:'notification' });
89
+ return text('Notification sent.');
90
+ }
91
+
92
+ export async function launchApp(app, argv = [], options = {}) {
93
+ const pid = spawnDetached(app, argv, { cwd:options.cwd || undefined });
94
+ return `Launched ${app} (pid ${pid})${options.cwd ? ` in ${options.cwd}` : ''}.`;
95
+ }
96
+
97
+ export async function openPath(target) {
98
+ if (!commandExists('xdg-open')) unavailable('Open path', 'xdg-open is required');
99
+ spawnDetached('xdg-open', [target]);
100
+ }
101
+
102
+ export async function revealPath(target) {
103
+ if (!commandExists('xdg-open')) unavailable('Reveal path', 'xdg-open is required');
104
+ spawnDetached('xdg-open', [path.dirname(target)]);
105
+ }