@remcp/runtime 0.2.99 → 0.2.101

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.101 — 2026-09-22
4
+
5
+ - macOS `record_screen` resolves FFmpeg from standard Homebrew locations when launchd's minimal `PATH` cannot see it.
6
+ - AVFoundation screen capture now enumerates devices and selects the actual `Capture screen` index instead of assuming input 1.
7
+ - Capability discovery stays conservative: no recorder backend means no `record_screen`; Linux Wayland still requires `wf-recorder`.
8
+ - Added regression coverage for Homebrew discovery and real-world AVFoundation ordering where input 1 is OBS Virtual Camera and the screen is input 3.
9
+
10
+ ## 0.2.100 — 2026-09-22
11
+
12
+ - Wayland portal-backed region screenshots clip compositor window bounds to the visible virtual desktop before FFmpeg cropping.
13
+ - Portal crop geometry translates negative virtual-desktop origins and scales logical coordinates to the captured PNG dimensions.
14
+ - Partially visible windows return the visible pixels with an explicit clipping summary; fully invisible regions fail closed.
15
+ - Added regressions for oversized/off-screen windows and multi-monitor scaling, plus a real GNOME Wayland 931×910 → 931×900 capture proof.
16
+
3
17
  ## 0.2.99 — 2026-09-22
4
18
 
5
19
  - 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.101",
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
 
@@ -3,6 +3,7 @@ import net from 'node:net';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
+ import { existsSync } from 'node:fs';
6
7
  import { stat } from 'node:fs/promises';
7
8
  import { assertAllowedCommand } from '../policy.mjs';
8
9
  import { isWaylandSession } from '../screenshot-portal.mjs';
@@ -245,17 +246,54 @@ export async function powerAction(args) {
245
246
  return text(`${policy.note ? `${policy.note}\n` : ''}Power action ${action} requested.`);
246
247
  }
247
248
 
249
+ export function resolveRecordScreenFfmpeg({
250
+ platform = process.platform,
251
+ commandExistsFn = commandExists,
252
+ existsSyncFn = existsSync,
253
+ } = {}) {
254
+ if (commandExistsFn('ffmpeg')) return 'ffmpeg';
255
+ if (platform === 'darwin') {
256
+ for (const candidate of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']) {
257
+ if (existsSyncFn(candidate)) return candidate;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
263
+ export function parseAvfoundationScreenInput(output) {
264
+ for (const line of String(output || '').split(/\r?\n/)) {
265
+ const match = line.match(/\[(\d+)\]\s+Capture screen(?:\s+\d+)?\s*$/i);
266
+ if (match) return match[1];
267
+ }
268
+ return null;
269
+ }
270
+
271
+ async function avfoundationScreenInput(ffmpeg) {
272
+ const result = await runFile(ffmpeg, [
273
+ '-hide_banner',
274
+ '-f','avfoundation',
275
+ '-list_devices','true',
276
+ '-i','',
277
+ ], { label:'AVFoundation device inventory', timeout:10_000, allowFailure:true });
278
+ const index = parseAvfoundationScreenInput(`${result.stderr || ''}\n${result.stdout || ''}`);
279
+ if (!index) unavailable('Screen recording', 'ffmpeg could not find an AVFoundation Capture screen input');
280
+ return index;
281
+ }
282
+
248
283
  export function recordScreenAvailable({
249
284
  platform = process.platform,
250
285
  wayland = platform === 'linux' ? isWaylandSession() : false,
251
286
  display = process.env.DISPLAY || '',
252
287
  commandExistsFn = commandExists,
288
+ existsSyncFn = existsSync,
253
289
  } = {}) {
254
290
  if (platform === 'linux') {
255
291
  if (wayland) return commandExistsFn('wf-recorder') && commandExistsFn('timeout');
256
292
  return Boolean(String(display).trim()) && commandExistsFn('ffmpeg');
257
293
  }
258
- if (platform === 'darwin' || platform === 'win32') return commandExistsFn('ffmpeg');
294
+ if (platform === 'darwin' || platform === 'win32') {
295
+ return Boolean(resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }));
296
+ }
259
297
  return false;
260
298
  }
261
299
 
@@ -268,22 +306,26 @@ export async function recordScreen(args) {
268
306
  const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',destination,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
269
307
  if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
270
308
  } else {
309
+ const ffmpeg = resolveRecordScreenFfmpeg();
271
310
  if (wayland) {
272
311
  unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
273
312
  }
274
313
  if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) {
275
314
  unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
276
315
  }
277
- if (!commandExists('ffmpeg')) {
316
+ if (!ffmpeg) {
278
317
  if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
279
318
  if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
280
319
  unavailable('Screen recording', 'ffmpeg is required on X11');
281
320
  }
282
321
  let argv;
283
322
  if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',destination];
284
- else if (process.platform === 'darwin') argv=['-y','-f','avfoundation','-framerate',String(fps),'-i','1:none','-t',String(seconds),'-pix_fmt','yuv420p',destination];
323
+ else if (process.platform === 'darwin') {
324
+ const input = await avfoundationScreenInput(ffmpeg);
325
+ argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
326
+ }
285
327
  else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
286
- await runFile('ffmpeg', argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
328
+ await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
287
329
  }
288
330
  const info = await stat(destination);
289
331
  return jsonResult({ path:destination, bytes:info.size, duration_seconds:seconds, format:path.extname(destination).slice(1) || 'mp4' });