@remcp/runtime 0.2.99 → 0.2.100

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.100 — 2026-09-22
4
+
5
+ - Wayland portal-backed region screenshots clip compositor window bounds to the visible virtual desktop before FFmpeg cropping.
6
+ - Portal crop geometry translates negative virtual-desktop origins and scales logical coordinates to the captured PNG dimensions.
7
+ - Partially visible windows return the visible pixels with an explicit clipping summary; fully invisible regions fail closed.
8
+ - Added regressions for oversized/off-screen windows and multi-monitor scaling, plus a real GNOME Wayland 931×910 → 931×900 capture proof.
9
+
3
10
  ## 0.2.99 — 2026-09-22
4
11
 
5
12
  - Lockstep runtime republish for the release-pipeline catalog synchronization fix.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.99",
3
+ "version": "0.2.100",
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",
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import process from 'node:process';
3
+ import { open } from 'node:fs/promises';
3
4
 
4
5
  import { image, multi, text } from '../util.mjs';
5
6
  import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
@@ -1302,6 +1303,79 @@ export async function displayInventory() {
1302
1303
  return jsonResult(rows);
1303
1304
  }
1304
1305
 
1306
+ async function pngDimensions(file) {
1307
+ const handle = await open(file, 'r');
1308
+ try {
1309
+ const buffer = Buffer.alloc(24);
1310
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
1311
+ if (bytesRead < buffer.length || buffer.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') return null;
1312
+ const width = buffer.readUInt32BE(16);
1313
+ const height = buffer.readUInt32BE(20);
1314
+ if (!width || !height) return null;
1315
+ return { width, height };
1316
+ } finally {
1317
+ await handle.close();
1318
+ }
1319
+ }
1320
+
1321
+ export function portalCropGeometry(rect, displays = [], bitmap = null) {
1322
+ const requested = {
1323
+ x: Math.trunc(Number(rect?.x)),
1324
+ y: Math.trunc(Number(rect?.y)),
1325
+ width: Math.trunc(Number(rect?.width)),
1326
+ height: Math.trunc(Number(rect?.height)),
1327
+ };
1328
+ if (![requested.x,requested.y,requested.width,requested.height].every(Number.isFinite)
1329
+ || requested.width <= 0 || requested.height <= 0) {
1330
+ throw new Error('portal crop requires a finite positive rectangle');
1331
+ }
1332
+ const visibleDisplays = Array.isArray(displays) ? displays.filter(item => {
1333
+ const x = Number(item?.x), y = Number(item?.y), width = Number(item?.width), height = Number(item?.height);
1334
+ return [x,y,width,height].every(Number.isFinite) && width > 0 && height > 0;
1335
+ }) : [];
1336
+ const bitmapWidth = Math.trunc(Number(bitmap?.width));
1337
+ const bitmapHeight = Math.trunc(Number(bitmap?.height));
1338
+ const hasBitmap = Number.isFinite(bitmapWidth) && bitmapWidth > 0 && Number.isFinite(bitmapHeight) && bitmapHeight > 0;
1339
+
1340
+ let minX = 0, minY = 0;
1341
+ let maxX = hasBitmap ? bitmapWidth : requested.x + requested.width;
1342
+ let maxY = hasBitmap ? bitmapHeight : requested.y + requested.height;
1343
+ if (visibleDisplays.length) {
1344
+ minX = Math.min(...visibleDisplays.map(item => Number(item.x)));
1345
+ minY = Math.min(...visibleDisplays.map(item => Number(item.y)));
1346
+ maxX = Math.max(...visibleDisplays.map(item => Number(item.x) + Number(item.width)));
1347
+ maxY = Math.max(...visibleDisplays.map(item => Number(item.y) + Number(item.height)));
1348
+ }
1349
+ const left = Math.max(requested.x, minX);
1350
+ const top = Math.max(requested.y, minY);
1351
+ const right = Math.min(requested.x + requested.width, maxX);
1352
+ const bottom = Math.min(requested.y + requested.height, maxY);
1353
+ if (right <= left || bottom <= top) throw new Error('screenshot region is outside visible desktop bounds');
1354
+
1355
+ const virtualWidth = maxX - minX;
1356
+ const virtualHeight = maxY - minY;
1357
+ const scaleX = hasBitmap && virtualWidth > 0 ? bitmapWidth / virtualWidth : 1;
1358
+ const scaleY = hasBitmap && virtualHeight > 0 ? bitmapHeight / virtualHeight : 1;
1359
+ const x = Math.max(0, Math.floor((left - minX) * scaleX));
1360
+ const y = Math.max(0, Math.floor((top - minY) * scaleY));
1361
+ const sourceRight = hasBitmap
1362
+ ? Math.min(bitmapWidth, Math.ceil((right - minX) * scaleX))
1363
+ : Math.ceil((right - minX) * scaleX);
1364
+ const sourceBottom = hasBitmap
1365
+ ? Math.min(bitmapHeight, Math.ceil((bottom - minY) * scaleY))
1366
+ : Math.ceil((bottom - minY) * scaleY);
1367
+ const width = sourceRight - x;
1368
+ const height = sourceBottom - y;
1369
+ if (width <= 0 || height <= 0) throw new Error('screenshot region has no visible portal pixels');
1370
+
1371
+ return {
1372
+ x, y, width, height,
1373
+ visible: { x:left, y:top, width:right-left, height:bottom-top },
1374
+ clipped: left !== requested.x || top !== requested.y
1375
+ || right !== requested.x + requested.width || bottom !== requested.y + requested.height,
1376
+ };
1377
+ }
1378
+
1305
1379
  export async function screenshotRegion(args = {}) {
1306
1380
  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));
1307
1381
  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');
@@ -1309,19 +1383,30 @@ export async function screenshotRegion(args = {}) {
1309
1383
  const dir = await tempDir('remcp-region-');
1310
1384
  const target = path.join(dir, 'region.png');
1311
1385
  const full = path.join(dir, 'full.png');
1386
+ let captured = { x, y, width, height };
1387
+ let clipped = false;
1312
1388
  try {
1313
1389
  if (commandExists('grim')) {
1314
1390
  await runFile('grim', ['-g',`${x},${y} ${width}x${height}`,target], { label:'screenshot region' });
1315
1391
  } else if (isWaylandSession() && commandExists('ffmpeg')) {
1316
1392
  await capturePortalScreenshot(full);
1317
- 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 });
1393
+ const inventory = await displayInventory().catch(() => null);
1394
+ const displays = Array.isArray(inventory?.structuredContent?.data) ? inventory.structuredContent.data : [];
1395
+ const bitmap = await pngDimensions(full);
1396
+ const crop = portalCropGeometry({ x, y, width, height }, displays, bitmap);
1397
+ captured = crop.visible;
1398
+ clipped = crop.clipped;
1399
+ 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 });
1318
1400
  } else if (commandExists('import')) {
1319
1401
  await runFile('import', ['-window','root','-crop',`${width}x${height}+${x}+${y}`,target], { label:'screenshot region' });
1320
1402
  } else {
1321
1403
  unavailable('Region screenshots', isWaylandSession() ? 'the XDG Desktop Portal plus ffmpeg, or grim, is required' : 'install ImageMagick import');
1322
1404
  }
1323
1405
  const {data} = await readPrivateTempFile(target, 4 * 1024 * 1024);
1324
- return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`}, image(data.toString('base64'),'image/png')]);
1406
+ const summary = clipped
1407
+ ? `Captured visible ${captured.width}x${captured.height} at ${captured.x},${captured.y}; clipped from requested ${width}x${height} at ${x},${y}.`
1408
+ : `Captured ${width}x${height} at ${x},${y}.`;
1409
+ return multi([{type:'text',text:summary}, image(data.toString('base64'),'image/png')]);
1325
1410
  } finally { await removeTemp(dir); }
1326
1411
  }
1327
1412