@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 +14 -0
- package/package.json +1 -1
- package/src/extended/desktop-linux.mjs +87 -2
- package/src/extended/diagnostics.mjs +46 -4
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,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
|
-
|
|
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
|
-
|
|
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')
|
|
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 (!
|
|
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')
|
|
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(
|
|
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' });
|