@remcp/runtime 0.2.104 → 0.2.106
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 +20 -4
- package/package.json +1 -1
- package/src/extended/desktop-linux.mjs +84 -15
- package/src/extended/desktop.mjs +1 -5
- package/src/helpers/wayland-eis-helper.py +30 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.106 — 2026-09-22
|
|
4
|
+
|
|
5
|
+
- Wayland EIS records the emulated device used for each button press and sends the matching release through that same device, preventing cross-region/device drags from losing `pointerup`.
|
|
6
|
+
- Portal drag-and-drop adds source settle time plus bounded intermediate absolute motion events; live ASUS DOM read-back verified the complete `down → move → … → up` gesture.
|
|
7
|
+
- Semantic-target scroll validation moves to a known visible target before wheel injection, and Wayland `screenshot_region` prefers `grim` or GNOME Screenshot before the interactive portal when available.
|
|
8
|
+
- Current-tree validation passed 215/215 runtime tests and the full 80/80 live ASUS computer-use smoke.
|
|
9
|
+
- macOS runtime code and runtime tool names/schemas are unchanged from 0.2.105.
|
|
10
|
+
|
|
11
|
+
## 0.2.105 — 2026-09-22
|
|
12
|
+
|
|
13
|
+
- macOS top-level window IDs include a snapshot generation and retain cached app/title/bounds identity, so a changed process-local window index cannot silently retarget an action.
|
|
14
|
+
- Older cached handles recover the same unambiguous window after reorder; stale, fabricated, expired, or ambiguous handles fail closed and require `list_windows` refresh.
|
|
15
|
+
- Unit/regression coverage verifies generation-scoped IDs, PID consistency, legacy/fabricated handle rejection, and fail-closed stale-handle behavior.
|
|
16
|
+
- Runtime tool names and schemas are unchanged from 0.2.104.
|
|
17
|
+
|
|
3
18
|
## 0.2.104 — 2026-09-22
|
|
4
19
|
|
|
5
|
-
- Fresh installations default the narrow catastrophic-command guardrail to block
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
20
|
+
- Fresh installations default the narrow catastrophic-command guardrail to `block`; ordinary development, file, Git, package-manager, Docker, database, browser, and document workflows remain available.
|
|
21
|
+
- Explicit `warn`, `allow`, and unrestricted/godmode remain operator-controlled local opt-ins.
|
|
22
|
+
- Added an isolated 73-scenario Desktop Commander compatibility smoke matrix; all 73 scenarios pass with zero guardrail false positives, while genuinely catastrophic host power/destructive commands remain blocked before execution.
|
|
23
|
+
- Wayland portal pointer clicks now keep a 60 ms button-down interval before release so a transport-level success is not collapsed into a zero-duration gesture; the fix passed three consecutive full 80/80 live ASUS smoke runs.
|
|
24
|
+
- Runtime tool names and schemas are unchanged from 0.2.103.
|
|
9
25
|
|
|
10
26
|
## 0.2.103 — 2026-09-22
|
|
11
27
|
|
package/package.json
CHANGED
|
@@ -1147,6 +1147,10 @@ export async function pointer(args = {}) {
|
|
|
1147
1147
|
else if (action === 'move') throw new Error('x and y are required for pointer move');
|
|
1148
1148
|
const click = async () => {
|
|
1149
1149
|
await portalPointerButton(buttonName, true, portalOptions);
|
|
1150
|
+
// A zero-duration press/release can be accepted by the portal transport but
|
|
1151
|
+
// coalesced before the target toolkit observes a real click. Keep a short,
|
|
1152
|
+
// human-scale hold so successful return means a deliverable button gesture.
|
|
1153
|
+
await new Promise(resolve => setTimeout(resolve, 60));
|
|
1150
1154
|
await portalPointerButton(buttonName, false, portalOptions);
|
|
1151
1155
|
await new Promise(resolve => setTimeout(resolve, 40));
|
|
1152
1156
|
};
|
|
@@ -1199,11 +1203,32 @@ export async function dragDrop(args = {}) {
|
|
|
1199
1203
|
const runPortal = async timeoutMs => {
|
|
1200
1204
|
const button = String(args.button || 'left').toLowerCase();
|
|
1201
1205
|
const portalOptions = { timeoutMs };
|
|
1202
|
-
|
|
1206
|
+
const fromX = Number(args.from_x), fromY = Number(args.from_y);
|
|
1207
|
+
const toX = Number(args.to_x), toY = Number(args.to_y);
|
|
1208
|
+
await portalMoveTo(fromX, fromY, portalOptions);
|
|
1209
|
+
// Give the compositor/toolkit one frame to observe the source position before
|
|
1210
|
+
// button-down. A transport ACK only proves that the portal accepted the event.
|
|
1211
|
+
await new Promise(resolve => setTimeout(resolve, 60));
|
|
1203
1212
|
await portalPointerButton(button, true, portalOptions);
|
|
1204
|
-
await new Promise(resolve => setTimeout(resolve, clamp(args.hold_ms, 120,
|
|
1205
|
-
|
|
1206
|
-
|
|
1213
|
+
await new Promise(resolve => setTimeout(resolve, clamp(args.hold_ms, 120, 60, 5000)));
|
|
1214
|
+
|
|
1215
|
+
// A single absolute jump while the button is held can be coalesced by Mutter/
|
|
1216
|
+
// XWayland and never become a target-visible pointermove. Send a short,
|
|
1217
|
+
// human-like path so drag-aware toolkits observe movement before button-up.
|
|
1218
|
+
const distance = Math.hypot(toX - fromX, toY - fromY);
|
|
1219
|
+
const steps = Math.max(4, Math.min(20, Math.ceil(distance / 24)));
|
|
1220
|
+
const durationMs = clamp(args.duration_ms, 160, 80, 5000);
|
|
1221
|
+
const stepDelay = Math.max(8, Math.round(durationMs / steps));
|
|
1222
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
1223
|
+
const ratio = step / steps;
|
|
1224
|
+
await portalMoveTo(
|
|
1225
|
+
fromX + (toX - fromX) * ratio,
|
|
1226
|
+
fromY + (toY - fromY) * ratio,
|
|
1227
|
+
portalOptions,
|
|
1228
|
+
);
|
|
1229
|
+
await new Promise(resolve => setTimeout(resolve, stepDelay));
|
|
1230
|
+
}
|
|
1231
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
1207
1232
|
await portalPointerButton(button, false, portalOptions);
|
|
1208
1233
|
await new Promise(resolve => setTimeout(resolve, 80));
|
|
1209
1234
|
};
|
|
@@ -1404,6 +1429,24 @@ async function pngDimensions(file) {
|
|
|
1404
1429
|
}
|
|
1405
1430
|
}
|
|
1406
1431
|
|
|
1432
|
+
export function regionScreenshotBackends({
|
|
1433
|
+
wayland = isWaylandSession(),
|
|
1434
|
+
grim = commandExists('grim'),
|
|
1435
|
+
gnomeScreenshot = commandExists('gnome-screenshot'),
|
|
1436
|
+
ffmpeg = commandExists('ffmpeg'),
|
|
1437
|
+
imagemagick = commandExists('import'),
|
|
1438
|
+
} = {}) {
|
|
1439
|
+
const backends = [];
|
|
1440
|
+
if (grim) backends.push('grim');
|
|
1441
|
+
if (wayland) {
|
|
1442
|
+
if (gnomeScreenshot && ffmpeg) backends.push('gnome-screenshot');
|
|
1443
|
+
if (ffmpeg) backends.push('portal');
|
|
1444
|
+
} else if (imagemagick) {
|
|
1445
|
+
backends.push('imagemagick');
|
|
1446
|
+
}
|
|
1447
|
+
return backends;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1407
1450
|
export function portalCropGeometry(rect, displays = [], bitmap = null) {
|
|
1408
1451
|
const requested = {
|
|
1409
1452
|
x: Math.trunc(Number(rect?.x)),
|
|
@@ -1471,28 +1514,54 @@ export async function screenshotRegion(args = {}) {
|
|
|
1471
1514
|
const full = path.join(dir, 'full.png');
|
|
1472
1515
|
let captured = { x, y, width, height };
|
|
1473
1516
|
let clipped = false;
|
|
1517
|
+
let backend = '';
|
|
1474
1518
|
try {
|
|
1475
|
-
|
|
1476
|
-
await runFile('grim', ['-g',`${x},${y} ${width}x${height}`,target], { label:'screenshot region' });
|
|
1477
|
-
} else if (isWaylandSession() && commandExists('ffmpeg')) {
|
|
1478
|
-
await capturePortalScreenshot(full);
|
|
1519
|
+
const cropFullDesktop = async () => {
|
|
1479
1520
|
const inventory = await displayInventory().catch(() => null);
|
|
1480
1521
|
const displays = Array.isArray(inventory?.structuredContent?.data) ? inventory.structuredContent.data : [];
|
|
1481
1522
|
const bitmap = await pngDimensions(full);
|
|
1482
1523
|
const crop = portalCropGeometry({ x, y, width, height }, displays, bitmap);
|
|
1483
1524
|
captured = crop.visible;
|
|
1484
1525
|
clipped = crop.clipped;
|
|
1485
|
-
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
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1526
|
+
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 });
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
const candidates = regionScreenshotBackends();
|
|
1530
|
+
if (!candidates.length) {
|
|
1531
|
+
unavailable('Region screenshots', isWaylandSession()
|
|
1532
|
+
? 'install grim, or gnome-screenshot/ffmpeg, or provide the XDG Desktop Portal plus ffmpeg'
|
|
1533
|
+
: 'install ImageMagick import');
|
|
1490
1534
|
}
|
|
1535
|
+
|
|
1536
|
+
let lastError = null;
|
|
1537
|
+
for (const candidate of candidates) {
|
|
1538
|
+
try {
|
|
1539
|
+
if (candidate === 'grim') {
|
|
1540
|
+
await runFile('grim', ['-g',`${x},${y} ${width}x${height}`,target], { label:'screenshot region' });
|
|
1541
|
+
} else if (candidate === 'gnome-screenshot') {
|
|
1542
|
+
await runFile('gnome-screenshot', ['-f',full], { label:'GNOME screenshot', timeout:10_000, maxBuffer:1024*1024 });
|
|
1543
|
+
await cropFullDesktop();
|
|
1544
|
+
} else if (candidate === 'portal') {
|
|
1545
|
+
await capturePortalScreenshot(full);
|
|
1546
|
+
await cropFullDesktop();
|
|
1547
|
+
} else if (candidate === 'imagemagick') {
|
|
1548
|
+
await runFile('import', ['-window','root','-crop',`${width}x${height}+${x}+${y}`,target], { label:'screenshot region' });
|
|
1549
|
+
}
|
|
1550
|
+
backend = candidate;
|
|
1551
|
+
lastError = null;
|
|
1552
|
+
break;
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (error?.code === 'PORTAL_CANCELLED') throw error;
|
|
1555
|
+
lastError = error;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (lastError) throw lastError;
|
|
1559
|
+
|
|
1491
1560
|
const {data} = await readPrivateTempFile(target, 4 * 1024 * 1024);
|
|
1492
|
-
const
|
|
1561
|
+
const baseSummary = clipped
|
|
1493
1562
|
? `Captured visible ${captured.width}x${captured.height} at ${captured.x},${captured.y}; clipped from requested ${width}x${height} at ${x},${y}.`
|
|
1494
1563
|
: `Captured ${width}x${height} at ${x},${y}.`;
|
|
1495
|
-
return multi([{type:'text',text:
|
|
1564
|
+
return multi([{type:'text',text:`${baseSummary} Backend: ${backend}.`}, image(data.toString('base64'),'image/png')]);
|
|
1496
1565
|
} finally { await removeTemp(dir); }
|
|
1497
1566
|
}
|
|
1498
1567
|
|
package/src/extended/desktop.mjs
CHANGED
|
@@ -355,11 +355,7 @@ export async function uiAction(args = {}) {
|
|
|
355
355
|
if (['set_value','set_range_value'].includes(action) && resolved.value == null) throw new Error(`ui_action ${action} requires value`);
|
|
356
356
|
return adapter.uiAction(resolved);
|
|
357
357
|
}
|
|
358
|
-
export async function keyboard(args = {}) {
|
|
359
|
-
const explicitWindowTarget = explicitTypeTextWindowTarget(args, false);
|
|
360
|
-
if (explicitWindowTarget) await windowAction({ action:'focus', ...explicitWindowTarget });
|
|
361
|
-
return adapter.keyboard(args);
|
|
362
|
-
}
|
|
358
|
+
export async function keyboard(args = {}) { return adapter.keyboard(args); }
|
|
363
359
|
export async function pointer(args = {}) {
|
|
364
360
|
if (args.action === 'move' && ![args.x,args.y].every(value => Number.isFinite(Number(value)))) throw new Error('pointer move requires x and y');
|
|
365
361
|
return adapter.pointer(args);
|
|
@@ -248,6 +248,9 @@ class EiSender:
|
|
|
248
248
|
self.connected = False
|
|
249
249
|
self.pointer_device_key = None
|
|
250
250
|
self.pointer_position = None
|
|
251
|
+
# A press/release pair must stay on the same emulated device even if
|
|
252
|
+
# absolute motion crosses an EIS region and changes pointer_device_key.
|
|
253
|
+
self.button_device_keys = {}
|
|
251
254
|
|
|
252
255
|
def _device_key(self, ptr):
|
|
253
256
|
return int(ptr or 0)
|
|
@@ -267,6 +270,9 @@ class EiSender:
|
|
|
267
270
|
if key == self.pointer_device_key:
|
|
268
271
|
self.pointer_device_key = None
|
|
269
272
|
self.pointer_position = None
|
|
273
|
+
for button, device_key in list(self.button_device_keys.items()):
|
|
274
|
+
if device_key == key:
|
|
275
|
+
self.button_device_keys.pop(button, None)
|
|
270
276
|
if state:
|
|
271
277
|
if state["emulating"]:
|
|
272
278
|
try:
|
|
@@ -313,10 +319,14 @@ class EiSender:
|
|
|
313
319
|
state["emulating"] = True
|
|
314
320
|
elif event_type == EI_EVENT_DEVICE_PAUSED:
|
|
315
321
|
device = self.lib.ei_event_get_device(event)
|
|
316
|
-
|
|
322
|
+
key = self._device_key(device)
|
|
323
|
+
state = self.devices.get(key)
|
|
317
324
|
if state:
|
|
318
325
|
state["resumed"] = False
|
|
319
326
|
state["emulating"] = False
|
|
327
|
+
for button, device_key in list(self.button_device_keys.items()):
|
|
328
|
+
if device_key == key:
|
|
329
|
+
self.button_device_keys.pop(button, None)
|
|
320
330
|
elif event_type == EI_EVENT_DEVICE_REMOVED:
|
|
321
331
|
self._drop_device(self.lib.ei_event_get_device(event))
|
|
322
332
|
finally:
|
|
@@ -419,8 +429,24 @@ class EiSender:
|
|
|
419
429
|
self.remember_pointer(device, (x, y))
|
|
420
430
|
self.frame(device)
|
|
421
431
|
elif op == "button":
|
|
422
|
-
|
|
423
|
-
|
|
432
|
+
button = int(payload["button"])
|
|
433
|
+
pressed = bool(payload.get("pressed"))
|
|
434
|
+
if pressed:
|
|
435
|
+
device = self.device_for_pointer_context(EI_CAP_BUTTON)
|
|
436
|
+
self.button_device_keys[button] = self._device_key(device)
|
|
437
|
+
else:
|
|
438
|
+
key = self.button_device_keys.pop(button, None)
|
|
439
|
+
state = self.devices.get(key) if key else None
|
|
440
|
+
if (
|
|
441
|
+
state
|
|
442
|
+
and state["resumed"]
|
|
443
|
+
and state["emulating"]
|
|
444
|
+
and self.lib.ei_device_has_capability(state["ptr"], EI_CAP_BUTTON)
|
|
445
|
+
):
|
|
446
|
+
device = state["ptr"]
|
|
447
|
+
else:
|
|
448
|
+
device = self.device_for_pointer_context(EI_CAP_BUTTON)
|
|
449
|
+
self.lib.ei_device_button_button(device, button, pressed)
|
|
424
450
|
self.frame(device)
|
|
425
451
|
elif op == "scroll":
|
|
426
452
|
device = self.device_for_pointer_context(EI_CAP_SCROLL)
|
|
@@ -446,6 +472,7 @@ class EiSender:
|
|
|
446
472
|
except Exception:
|
|
447
473
|
pass
|
|
448
474
|
self.devices.clear()
|
|
475
|
+
self.button_device_keys.clear()
|
|
449
476
|
if self.ei:
|
|
450
477
|
self.lib.ei_unref(self.ei)
|
|
451
478
|
self.ei = None
|