@remcp/runtime 0.2.105 → 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 CHANGED
@@ -1,10 +1,18 @@
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
+
3
11
  ## 0.2.105 — 2026-09-22
4
12
 
5
- - macOS top-level window IDs now include a snapshot generation and retain cached app/title/bounds identity, so a changed process-local window index cannot silently retarget an action.
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.
6
14
  - Older cached handles recover the same unambiguous window after reorder; stale, fabricated, expired, or ambiguous handles fail closed and require `list_windows` refresh.
7
- - Real Mac proof on a multi-window Chrome process verified `g1 → g2` refresh, old-handle exact focus/capture resolution, fabricated-generation rejection, and Accessibility traversal.
15
+ - Unit/regression coverage verifies generation-scoped IDs, PID consistency, legacy/fabricated handle rejection, and fail-closed stale-handle behavior.
8
16
  - Runtime tool names and schemas are unchanged from 0.2.104.
9
17
 
10
18
  ## 0.2.104 — 2026-09-22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.105",
3
+ "version": "0.2.106",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1203,11 +1203,32 @@ export async function dragDrop(args = {}) {
1203
1203
  const runPortal = async timeoutMs => {
1204
1204
  const button = String(args.button || 'left').toLowerCase();
1205
1205
  const portalOptions = { timeoutMs };
1206
- await portalMoveTo(Number(args.from_x), Number(args.from_y), portalOptions);
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));
1207
1212
  await portalPointerButton(button, true, portalOptions);
1208
- await new Promise(resolve => setTimeout(resolve, clamp(args.hold_ms, 120, 0, 5000)));
1209
- await portalMoveTo(Number(args.to_x), Number(args.to_y), portalOptions);
1210
- await new Promise(resolve => setTimeout(resolve, clamp(args.duration_ms, 120, 0, 5000)));
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));
1211
1232
  await portalPointerButton(button, false, portalOptions);
1212
1233
  await new Promise(resolve => setTimeout(resolve, 80));
1213
1234
  };
@@ -1408,6 +1429,24 @@ async function pngDimensions(file) {
1408
1429
  }
1409
1430
  }
1410
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
+
1411
1450
  export function portalCropGeometry(rect, displays = [], bitmap = null) {
1412
1451
  const requested = {
1413
1452
  x: Math.trunc(Number(rect?.x)),
@@ -1475,28 +1514,54 @@ export async function screenshotRegion(args = {}) {
1475
1514
  const full = path.join(dir, 'full.png');
1476
1515
  let captured = { x, y, width, height };
1477
1516
  let clipped = false;
1517
+ let backend = '';
1478
1518
  try {
1479
- if (commandExists('grim')) {
1480
- await runFile('grim', ['-g',`${x},${y} ${width}x${height}`,target], { label:'screenshot region' });
1481
- } else if (isWaylandSession() && commandExists('ffmpeg')) {
1482
- await capturePortalScreenshot(full);
1519
+ const cropFullDesktop = async () => {
1483
1520
  const inventory = await displayInventory().catch(() => null);
1484
1521
  const displays = Array.isArray(inventory?.structuredContent?.data) ? inventory.structuredContent.data : [];
1485
1522
  const bitmap = await pngDimensions(full);
1486
1523
  const crop = portalCropGeometry({ x, y, width, height }, displays, bitmap);
1487
1524
  captured = crop.visible;
1488
1525
  clipped = crop.clipped;
1489
- 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 portal screenshot', timeout:30_000, maxBuffer:4*1024*1024 });
1490
- } else if (commandExists('import')) {
1491
- await runFile('import', ['-window','root','-crop',`${width}x${height}+${x}+${y}`,target], { label:'screenshot region' });
1492
- } else {
1493
- unavailable('Region screenshots', isWaylandSession() ? 'the XDG Desktop Portal plus ffmpeg, or grim, is required' : 'install ImageMagick import');
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');
1494
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
+
1495
1560
  const {data} = await readPrivateTempFile(target, 4 * 1024 * 1024);
1496
- const summary = clipped
1561
+ const baseSummary = clipped
1497
1562
  ? `Captured visible ${captured.width}x${captured.height} at ${captured.x},${captured.y}; clipped from requested ${width}x${height} at ${x},${y}.`
1498
1563
  : `Captured ${width}x${height} at ${x},${y}.`;
1499
- return multi([{type:'text',text:summary}, image(data.toString('base64'),'image/png')]);
1564
+ return multi([{type:'text',text:`${baseSummary} Backend: ${backend}.`}, image(data.toString('base64'),'image/png')]);
1500
1565
  } finally { await removeTemp(dir); }
1501
1566
  }
1502
1567
 
@@ -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
- state = self.devices.get(self._device_key(device))
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
- device = self.device_for_pointer_context(EI_CAP_BUTTON)
423
- self.lib.ei_device_button_button(device, int(payload["button"]), bool(payload.get("pressed")))
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