@remcp/runtime 0.2.110 → 0.2.112
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
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.112 — 2026-09-25
|
|
4
|
+
|
|
5
|
+
- Linux EIS absolute pointer motion converts desktop-wide coordinates into the selected libei region before dispatch and keeps the current pointer device when it remains valid, eliminating accepted-but-undelivered absolute moves.
|
|
6
|
+
- Wayland portal drag-and-drop uses one region-safe target transition while pressed, then reasserts the final target before release so `pointerdown → pointermove → pointerup` remains intact.
|
|
7
|
+
- The live computer-use smoke is serialized per user to prevent concurrent desktop-input races; the final ASUS GNOME Wayland run passed 82/82 checks. Runtime tool names and schemas are unchanged.
|
|
8
|
+
|
|
9
|
+
## 0.2.111 — 2026-09-25
|
|
10
|
+
|
|
11
|
+
- `read_document` reads `.xlsx` files again while `allowedRoots` confinement is enabled: the reader no longer re-enters its own temp staging path through the allowlist.
|
|
12
|
+
- Linux window inventory refreshes X11 rows after the AT-SPI walk, window selectors resolve X11/XWayland targets without a full semantic rebuild, and `window_action focus` verifies direct XWayland activation before falling back to portal cycling. Runtime tool names and schemas are unchanged.
|
|
13
|
+
|
|
3
14
|
## 0.2.110 — 2026-09-25
|
|
4
15
|
|
|
5
16
|
- Bulk handlers (`read_multiple_files`, `read_files`, `replace_in_files`, `write_files`, `copy_paths`, `move_paths`, `delete_paths`, `create_archive`) and the directory walk now honour client cancellation between items, so an aborted call stops at the next item boundary instead of finishing on the device; single-file and external-tool (`tar`/`unzip`) operations remain atomic and are not interrupted mid-step.
|
package/package.json
CHANGED
|
@@ -55,7 +55,7 @@ async function x11PixelBounds(id) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export async function listWindows() {
|
|
58
|
-
|
|
58
|
+
let wmRows = commandExists('wmctrl')
|
|
59
59
|
? parseWindows((await runFile('wmctrl', ['-lpGx'], { label: 'window inventory', allowFailure:true, timeout:3000 })).stdout)
|
|
60
60
|
: [];
|
|
61
61
|
if (!isWaylandSession()) {
|
|
@@ -85,6 +85,15 @@ export async function listWindows() {
|
|
|
85
85
|
});
|
|
86
86
|
} catch {}
|
|
87
87
|
|
|
88
|
+
// AT-SPI enumeration can take several seconds on a busy desktop. Refresh the cheap
|
|
89
|
+
// X11 inventory afterwards so an XWayland window that appeared while AT-SPI was
|
|
90
|
+
// walking the tree is merged with its semantic window instead of being misclassified
|
|
91
|
+
// as native Wayland for the rest of this call.
|
|
92
|
+
if (commandExists('wmctrl')) {
|
|
93
|
+
const refreshedWmRows = parseWindows((await runFile('wmctrl', ['-lpGx'], { label:'window inventory refresh', allowFailure:true, timeout:3000 })).stdout);
|
|
94
|
+
if (refreshedWmRows.length) wmRows = refreshedWmRows;
|
|
95
|
+
}
|
|
96
|
+
|
|
88
97
|
const pixelBounds = new Map();
|
|
89
98
|
if (commandExists('xwininfo')) {
|
|
90
99
|
// wmctrl geometry on Mutter/XWayland is decoration-shifted and cannot be used for pixel crops.
|
|
@@ -151,18 +160,50 @@ export async function listWindows() {
|
|
|
151
160
|
return jsonResult(rows);
|
|
152
161
|
}
|
|
153
162
|
|
|
163
|
+
function windowSelector(args = {}) {
|
|
164
|
+
return {
|
|
165
|
+
id: optionalString(args.id || args.window_id || args.windowId),
|
|
166
|
+
pid: Number.isInteger(Number(args.pid)) ? Number(args.pid) : null,
|
|
167
|
+
app: optionalString(args.app),
|
|
168
|
+
title: optionalString(args.title || args.window_title || args.windowTitle),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function windowMatchesSelector(item, selector) {
|
|
173
|
+
return (!selector.id || item.id === selector.id || item.wm_id === selector.id || item.ui_id === selector.id)
|
|
174
|
+
&& (selector.pid == null || Number(item.pid) === selector.pid)
|
|
175
|
+
&& (!selector.app || String(item.app || '').toLowerCase().includes(selector.app.toLowerCase()))
|
|
176
|
+
&& (!selector.title || String(item.title || '').toLowerCase().includes(selector.title.toLowerCase()));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function fastX11WindowMatch(args) {
|
|
180
|
+
if (!commandExists('wmctrl')) return null;
|
|
181
|
+
const selector = windowSelector(args);
|
|
182
|
+
const rows = parseWindows((await runFile('wmctrl', ['-lpGx'], {
|
|
183
|
+
label:'window match',
|
|
184
|
+
allowFailure:true,
|
|
185
|
+
timeout:2000,
|
|
186
|
+
})).stdout);
|
|
187
|
+
const row = rows.find(item => windowMatchesSelector({ ...item, wm_id:item.id, ui_id:null }, selector));
|
|
188
|
+
if (!row) return null;
|
|
189
|
+
return {
|
|
190
|
+
...row,
|
|
191
|
+
wm_id:row.id,
|
|
192
|
+
ui_id:null,
|
|
193
|
+
backend:isWaylandSession() ? 'xwayland' : 'x11',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
154
197
|
async function windowMatch(args) {
|
|
198
|
+
// Most desktop actions target an X11/XWayland window. Resolve that cheap native id
|
|
199
|
+
// first instead of rebuilding the entire AT-SPI inventory before every action.
|
|
200
|
+
// Native Wayland windows have no wmctrl row and fall through to the semantic inventory.
|
|
201
|
+
const fast = await fastX11WindowMatch(args);
|
|
202
|
+
if (fast) return fast;
|
|
203
|
+
|
|
155
204
|
const rows = JSON.parse((await listWindows()).content[0].text);
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const app = optionalString(args.app);
|
|
159
|
-
const title = optionalString(args.title || args.window_title || args.windowTitle);
|
|
160
|
-
const row = rows.find(item =>
|
|
161
|
-
(!id || item.id === id || item.wm_id === id || item.ui_id === id)
|
|
162
|
-
&& (pid == null || item.pid === pid)
|
|
163
|
-
&& (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
|
|
164
|
-
&& (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
|
|
165
|
-
);
|
|
205
|
+
const selector = windowSelector(args);
|
|
206
|
+
const row = rows.find(item => windowMatchesSelector(item, selector));
|
|
166
207
|
if (!row) throw new Error('No matching window found');
|
|
167
208
|
return row;
|
|
168
209
|
}
|
|
@@ -502,7 +543,24 @@ export async function windowAction(args) {
|
|
|
502
543
|
if (focusState.exact) return text('Window action focus completed via verified AT-SPI focus.');
|
|
503
544
|
} catch {}
|
|
504
545
|
}
|
|
505
|
-
|
|
546
|
+
// XWayland windows have a real X11 wm_id even inside a Wayland session. Prefer
|
|
547
|
+
// direct activation for those windows, verify the result through accessibility,
|
|
548
|
+
// and only fall back to compositor-level cycling if direct activation did not take.
|
|
549
|
+
// This avoids dozens of Alt+Esc/AT-SPI round trips on busy desktops.
|
|
550
|
+
if (row.wm_id && backend !== 'portal' && commandExists('wmctrl')) {
|
|
551
|
+
const activated = await runFile('wmctrl', ['-ia', row.wm_id], { label:'XWayland window focus', allowFailure:true, timeout:2000 });
|
|
552
|
+
if (activated.code === 0) {
|
|
553
|
+
if (commandExists('xdotool')) {
|
|
554
|
+
await runFile('xdotool', ['windowactivate','--sync',row.wm_id], { label:'XWayland window focus sync', allowFailure:true, timeout:3000 });
|
|
555
|
+
}
|
|
556
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
557
|
+
const focusState = await targetAccessibilityFocus(row);
|
|
558
|
+
if (focusState.exact) return text('Window action focus completed via verified XWayland activation.');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (backend === 'x11') unavailable('Wayland window focus', row.wm_id
|
|
562
|
+
? 'direct XWayland activation did not produce verifiable focus'
|
|
563
|
+
: 'the target is native Wayland and has no X11 window id; use backend=portal');
|
|
506
564
|
if (!waylandPortalCandidate()) unavailable('Wayland window focus', 'no XDG RemoteDesktop portal is available');
|
|
507
565
|
if (backend === 'auto' && !hasWaylandRemoteDesktopGrant()) portalPermissionHint('Wayland window focus');
|
|
508
566
|
const portalOptions = { timeoutMs: backend === 'portal' ? 120_000 : 2500 };
|
|
@@ -1212,22 +1270,16 @@ export async function dragDrop(args = {}) {
|
|
|
1212
1270
|
await portalPointerButton(button, true, portalOptions);
|
|
1213
1271
|
await new Promise(resolve => setTimeout(resolve, clamp(args.hold_ms, 120, 60, 5000)));
|
|
1214
1272
|
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
fromX + (toX - fromX) * ratio,
|
|
1226
|
-
fromY + (toY - fromY) * ratio,
|
|
1227
|
-
portalOptions,
|
|
1228
|
-
);
|
|
1229
|
-
await new Promise(resolve => setTimeout(resolve, stepDelay));
|
|
1230
|
-
}
|
|
1273
|
+
// Keep the pressed pointer on a single absolute-motion transition. Repeated
|
|
1274
|
+
// absolute moves can cross EIS region/device contexts and lose the logical
|
|
1275
|
+
// pressed state. The EIS helper converts the desktop target into region-local
|
|
1276
|
+
// coordinates, so one target move is sufficient and preserves buttons=1.
|
|
1277
|
+
await portalMoveTo(toX, toY, portalOptions);
|
|
1278
|
+
await new Promise(resolve => setTimeout(resolve, clamp(args.duration_ms, 160, 80, 5000)));
|
|
1279
|
+
// Reassert the final target immediately before release. On Mutter/XWayland this
|
|
1280
|
+
// mirrors a real pointer-up gesture and ensures the release is delivered as a
|
|
1281
|
+
// pointerup/mouseup at the dragged target instead of only clearing button state.
|
|
1282
|
+
await portalMoveTo(toX, toY, portalOptions);
|
|
1231
1283
|
await new Promise(resolve => setTimeout(resolve, 40));
|
|
1232
1284
|
await portalPointerButton(button, false, portalOptions);
|
|
1233
1285
|
await new Promise(resolve => setTimeout(resolve, 80));
|
|
@@ -334,10 +334,12 @@ function readWorksheetCells(xml, sharedStrings, maxCells) {
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
async function readXlsx(filePath, args) {
|
|
337
|
-
const source = await snapshotDocumentFile(filePath);
|
|
338
337
|
const dir = await tempDir('remcp-xlsx-read-');
|
|
339
338
|
try {
|
|
340
|
-
|
|
339
|
+
// extractZip already snapshots the user-approved source into a private temp file
|
|
340
|
+
// before invoking unzip. A second snapshot here used to re-open ReMCP's own temp
|
|
341
|
+
// path through allowedRoots, so read_document failed whenever confinement was on.
|
|
342
|
+
await extractZip(filePath, dir);
|
|
341
343
|
const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
|
|
342
344
|
const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
|
|
343
345
|
const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
|
|
@@ -347,7 +349,6 @@ async function readXlsx(filePath, args) {
|
|
|
347
349
|
const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
|
|
348
350
|
return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
|
|
349
351
|
} finally {
|
|
350
|
-
await removeTemp(source.dir);
|
|
351
352
|
await removeTemp(dir);
|
|
352
353
|
}
|
|
353
354
|
}
|
|
@@ -216,6 +216,8 @@ class EiSender:
|
|
|
216
216
|
self.lib.ei_device_has_capability.restype = ctypes.c_bool
|
|
217
217
|
self.lib.ei_device_get_region_at.argtypes = [c_void_p, ctypes.c_double, ctypes.c_double]
|
|
218
218
|
self.lib.ei_device_get_region_at.restype = c_void_p
|
|
219
|
+
self.lib.ei_region_convert_point.argtypes = [c_void_p, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)]
|
|
220
|
+
self.lib.ei_region_convert_point.restype = ctypes.c_bool
|
|
219
221
|
self.lib.ei_device_start_emulating.argtypes = [c_void_p, ctypes.c_uint32]
|
|
220
222
|
self.lib.ei_device_stop_emulating.argtypes = [c_void_p]
|
|
221
223
|
self.lib.ei_device_keyboard_key.argtypes = [c_void_p, ctypes.c_uint32, ctypes.c_bool]
|
|
@@ -367,17 +369,46 @@ class EiSender:
|
|
|
367
369
|
return state["ptr"]
|
|
368
370
|
raise RuntimeError(f"no resumed EIS device with capability {cap}")
|
|
369
371
|
|
|
372
|
+
def _absolute_motion_for_device(self, device, x, y):
|
|
373
|
+
if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
|
|
374
|
+
return None
|
|
375
|
+
region = self.lib.ei_device_get_region_at(device, float(x), float(y))
|
|
376
|
+
if not region:
|
|
377
|
+
return None
|
|
378
|
+
motion_x = ctypes.c_double(float(x))
|
|
379
|
+
motion_y = ctypes.c_double(float(y))
|
|
380
|
+
if not self.lib.ei_region_convert_point(region, ctypes.byref(motion_x), ctypes.byref(motion_y)):
|
|
381
|
+
raise RuntimeError(f"failed to convert desktop coordinate ({x}, {y}) into the selected EIS region")
|
|
382
|
+
return motion_x.value, motion_y.value
|
|
383
|
+
|
|
370
384
|
def device_for_absolute(self, x, y):
|
|
371
385
|
candidates = []
|
|
386
|
+
|
|
387
|
+
# Preserve pointer identity across absolute motion whenever the current
|
|
388
|
+
# device still covers the destination. This is required for pointer
|
|
389
|
+
# capture during drags: switching EIS devices mid-gesture can drop the
|
|
390
|
+
# captured pointer before its button release is observed.
|
|
391
|
+
preferred = self.devices.get(self.pointer_device_key)
|
|
392
|
+
if preferred and preferred["resumed"] and preferred["emulating"]:
|
|
393
|
+
device = preferred["ptr"]
|
|
394
|
+
if self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
|
|
395
|
+
candidates.append(device)
|
|
396
|
+
converted = self._absolute_motion_for_device(device, x, y)
|
|
397
|
+
if converted is not None:
|
|
398
|
+
return device, converted[0], converted[1]
|
|
399
|
+
|
|
372
400
|
for state in self.devices.values():
|
|
373
401
|
if not state["resumed"] or not state["emulating"]:
|
|
374
402
|
continue
|
|
375
403
|
device = state["ptr"]
|
|
404
|
+
if preferred and self._device_key(device) == self.pointer_device_key:
|
|
405
|
+
continue
|
|
376
406
|
if not self.lib.ei_device_has_capability(device, EI_CAP_POINTER_ABSOLUTE):
|
|
377
407
|
continue
|
|
378
408
|
candidates.append(device)
|
|
379
|
-
|
|
380
|
-
|
|
409
|
+
converted = self._absolute_motion_for_device(device, x, y)
|
|
410
|
+
if converted is not None:
|
|
411
|
+
return device, converted[0], converted[1]
|
|
381
412
|
if candidates:
|
|
382
413
|
raise RuntimeError(f"no absolute-pointer EIS region contains desktop coordinate ({x}, {y})")
|
|
383
414
|
raise RuntimeError("no resumed EIS device with absolute-pointer capability")
|
|
@@ -424,8 +455,8 @@ class EiSender:
|
|
|
424
455
|
elif op == "motion_absolute":
|
|
425
456
|
x = float(payload["x"])
|
|
426
457
|
y = float(payload["y"])
|
|
427
|
-
device = self.device_for_absolute(x, y)
|
|
428
|
-
self.lib.ei_device_pointer_motion_absolute(device,
|
|
458
|
+
device, motion_x, motion_y = self.device_for_absolute(x, y)
|
|
459
|
+
self.lib.ei_device_pointer_motion_absolute(device, motion_x, motion_y)
|
|
429
460
|
self.remember_pointer(device, (x, y))
|
|
430
461
|
self.frame(device)
|
|
431
462
|
elif op == "button":
|