@remcp/runtime 0.2.105 → 0.2.107

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.107",
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",
package/src/config.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { expandHome } from './util.mjs';
5
5
 
6
6
  const configDir = process.env.REMCP_RUNTIME_CONFIG_DIR || path.join(os.homedir(), '.config', 'remcp');
@@ -161,7 +161,17 @@ export function persistConfigValue(key, value) {
161
161
  const file = readConfigFile();
162
162
  const next = { ...file, [key]: value };
163
163
  mkdirSync(runtimeConfigDir, { recursive: true, mode: 0o700 });
164
- writeFileSync(runtimeConfigPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
164
+ const existing = lstatSync(runtimeConfigPath, { throwIfNoEntry: false });
165
+ if (existing && (existing.isSymbolicLink() || !existing.isFile())) throw new Error('runtime configuration path must be a regular file');
166
+ const temporary = `${runtimeConfigPath}.${process.pid}.${Date.now()}.tmp`;
167
+ try {
168
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
169
+ renameSync(temporary, runtimeConfigPath);
170
+ chmodSync(runtimeConfigPath, 0o600);
171
+ } catch (error) {
172
+ try { unlinkSync(temporary); } catch {}
173
+ throw error;
174
+ }
165
175
  return runtimeConfigPath;
166
176
  }
167
177
 
package/src/documents.mjs CHANGED
@@ -14,6 +14,8 @@ import { inflateRawSync, inflateSync } from 'node:zlib';
14
14
  // Nothing here writes, and nothing leaves the computer.
15
15
 
16
16
  const MAX_INFLATE_BYTES = 32 * 1024 * 1024;
17
+ const MAX_PDF_DECODED_BYTES = 128 * 1024 * 1024;
18
+ const MAX_PDF_STREAMS = 10_000;
17
19
 
18
20
  function inflate(buffer, raw = false) {
19
21
  try {
@@ -260,6 +262,8 @@ function pdfTextPieces(content) {
260
262
  export function readPdfText(buffer) {
261
263
  const raw = buffer.toString('latin1');
262
264
  const chunks = [];
265
+ let decodedBytes = 0;
266
+ let streamCount = 0;
263
267
  let index = 0;
264
268
  while (index < raw.length) {
265
269
  const streamStart = raw.indexOf('stream', index);
@@ -271,6 +275,9 @@ export function readPdfText(buffer) {
271
275
  if (end < 0) break;
272
276
  const body = Buffer.from(raw.slice(start, end), 'latin1');
273
277
  const decoded = body.subarray(0, 5).toString('latin1') === '<?xml' ? body : (inflate(body) ?? inflate(body, true) ?? body);
278
+ decodedBytes += decoded.length;
279
+ streamCount += 1;
280
+ if (decodedBytes > MAX_PDF_DECODED_BYTES || streamCount > MAX_PDF_STREAMS) throw new Error('PDF decoded content exceeds the safety limit');
274
281
  chunks.push(decoded.toString('latin1'));
275
282
  index = end + 'endstream'.length;
276
283
  }
@@ -1,12 +1,17 @@
1
1
  import process from 'node:process';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { isIP } from 'node:net';
4
5
  import { existsSync, mkdirSync } from 'node:fs';
5
6
  import { resolveSafePath } from '../util.mjs';
6
7
  import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
7
8
 
8
9
  const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
9
10
  const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
11
+ const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
12
+ const MAX_EVALUATION_BYTES = 256 * 1024;
13
+ const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
14
+ const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob):/i;
10
15
 
11
16
  let browserLaunchPromise = null;
12
17
 
@@ -86,11 +91,67 @@ function boundedText(value, max = 1000) {
86
91
  return text.length <= max ? text : `${text.slice(0, max)}…`;
87
92
  }
88
93
 
94
+ function normalizedHost(hostname) {
95
+ return String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
96
+ }
97
+
89
98
  function isLoopbackHost(hostname) {
90
- const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
99
+ const host = normalizedHost(hostname);
91
100
  return ['localhost', '127.0.0.1', '::1'].includes(host);
92
101
  }
93
102
 
103
+ function localBrowserNavigationAllowed() {
104
+ return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
105
+ .some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
106
+ }
107
+
108
+ function isPrivateIpv4(host) {
109
+ if (isIP(host) !== 4) return false;
110
+ const parts = host.split('.').map(Number);
111
+ const [a, b, c] = parts;
112
+ return a === 0
113
+ || a === 10
114
+ || a === 127
115
+ || (a === 100 && b >= 64 && b <= 127)
116
+ || (a === 169 && b === 254)
117
+ || (a === 172 && b >= 16 && b <= 31)
118
+ || (a === 192 && b === 0 && c === 0)
119
+ || (a === 192 && b === 0 && c === 2)
120
+ || (a === 192 && b === 168)
121
+ || (a === 198 && (b === 18 || b === 19))
122
+ || (a === 198 && b === 51 && c === 100)
123
+ || (a === 203 && b === 0 && c === 113)
124
+ || a >= 224;
125
+ }
126
+
127
+ function isPrivateIpv6(host) {
128
+ if (isIP(host) !== 6) return false;
129
+ if (host === '::' || host === '::1') return true;
130
+ if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith('ff')) return true;
131
+ const mapped = host.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
132
+ if (mapped) {
133
+ const high = Number.parseInt(mapped[1], 16);
134
+ const low = Number.parseInt(mapped[2], 16);
135
+ return isPrivateIpv4([high >> 8, high & 255, low >> 8, low & 255].join('.'));
136
+ }
137
+ return false;
138
+ }
139
+
140
+ function isPrivateBrowserHost(hostname) {
141
+ const host = normalizedHost(hostname);
142
+ if (!host) return true;
143
+ if (isPrivateIpv4(host) || isPrivateIpv6(host)) return true;
144
+ return host === 'localhost'
145
+ || host.endsWith('.localhost')
146
+ || host.endsWith('.local')
147
+ || host.endsWith('.internal')
148
+ || host.endsWith('.home.arpa')
149
+ || host.endsWith('.lan')
150
+ || host === 'localhost.localdomain'
151
+ || host === 'ip6-localhost'
152
+ || host === 'ip6-loopback';
153
+ }
154
+
94
155
  function endpointUrl(value) {
95
156
  let url;
96
157
  try { url = new URL(optionalString(value) || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT); }
@@ -100,13 +161,51 @@ function endpointUrl(value) {
100
161
  return url;
101
162
  }
102
163
 
164
+ function safeBrowserUrl(value, field = 'url') {
165
+ let url;
166
+ try { url = new URL(String(value || '')); }
167
+ catch { throw new Error(`${field} must be a valid absolute URL`); }
168
+ if (!BROWSER_PROTOCOLS.has(url.protocol)) throw new Error(`${field} must use http or https`);
169
+ if (url.username || url.password) throw new Error(`${field} must not embed credentials`);
170
+ if (!localBrowserNavigationAllowed() && isPrivateBrowserHost(url.hostname)) {
171
+ throw new Error(`${field} must not target loopback, private, or link-local addresses`);
172
+ }
173
+ return url.href;
174
+ }
175
+
176
+ function safePageUrl(value, field = 'browser page URL') {
177
+ if (String(value || '') === 'about:blank') return value;
178
+ return safeBrowserUrl(value, field);
179
+ }
180
+
181
+ function expressionHasPrivateDestination(expression) {
182
+ const urls = expression.match(/https?:\/\/[^\s"'`<>]+/gi) || [];
183
+ return urls.some(value => {
184
+ try { return isPrivateBrowserHost(new URL(value).hostname); }
185
+ catch { return false; }
186
+ });
187
+ }
188
+
189
+ function safeEvaluationExpression(value) {
190
+ const expression = String(value || '');
191
+ if (!expression.trim()) throw new Error('expression is required');
192
+ if (expression.includes('\0') || Buffer.byteLength(expression, 'utf8') > MAX_EVALUATION_BYTES) {
193
+ throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
194
+ }
195
+ if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
196
+ if (!localBrowserNavigationAllowed() && expressionHasPrivateDestination(expression)) {
197
+ throw new Error('expression contains a blocked private browser destination');
198
+ }
199
+ return expression;
200
+ }
201
+
103
202
  async function requestJson(pathname, endpoint, options = {}) {
104
203
  const base = endpointUrl(endpoint);
105
204
  const url = new URL(pathname, base);
106
205
  const controller = new AbortController();
107
206
  const timer = setTimeout(() => controller.abort(), clamp(options.timeout_ms, 5000, 100, 30_000));
108
207
  try {
109
- const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal });
208
+ const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal, redirect: 'error' });
110
209
  if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
111
210
  const declared = Number(response.headers.get('content-length') || 0);
112
211
  if (declared > MAX_DISCOVERY_BYTES) throw new Error(`CDP discovery response is too large (${declared} bytes)`);
@@ -144,7 +243,11 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
144
243
  export async function listBrowserTargets(endpoint) {
145
244
  await ensureBrowserEndpoint(endpoint);
146
245
  const list = await requestJson('/json/list', endpoint);
147
- return Array.isArray(list) ? list : [];
246
+ if (!Array.isArray(list)) return [];
247
+ return list.filter(item => {
248
+ if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) return false;
249
+ try { safePageUrl(item.url); return true; } catch { return false; }
250
+ });
148
251
  }
149
252
 
150
253
  async function chooseTarget(args = {}) {
@@ -152,12 +255,17 @@ async function chooseTarget(args = {}) {
152
255
  const id = optionalString(args.target_id || args.targetId);
153
256
  const title = optionalString(args.title);
154
257
  const urlContains = optionalString(args.url_contains || args.urlContains);
155
- const target = targets.find(item =>
258
+ const matched = targets.find(item =>
156
259
  (!id || item.id === id) &&
157
260
  (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase())) &&
158
261
  (!urlContains || String(item.url || '').toLowerCase().includes(urlContains.toLowerCase()))
159
- ) || targets[0];
160
- if (!target) unavailable('Browser control', 'no debuggable page target is available');
262
+ );
263
+ const hasSelector = Boolean(id || title || urlContains);
264
+ const target = matched || (hasSelector ? null : targets[0]);
265
+ if (!target) {
266
+ if (hasSelector) throw new Error('No browser page target matched the requested selector');
267
+ unavailable('Browser control', 'no debuggable page target is available');
268
+ }
161
269
  let socketUrl;
162
270
  try { socketUrl = new URL(String(target.webSocketDebuggerUrl)); }
163
271
  catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
@@ -192,8 +300,13 @@ class CdpSession {
192
300
  }
193
301
 
194
302
  onMessage(event) {
303
+ const raw = String(event.data);
304
+ if (Buffer.byteLength(raw, 'utf8') > MAX_CDP_MESSAGE_BYTES) {
305
+ this.close();
306
+ return;
307
+ }
195
308
  let message;
196
- try { message = JSON.parse(String(event.data)); } catch { return; }
309
+ try { message = JSON.parse(raw); } catch { return; }
197
310
  if (message.id && this.pending.has(message.id)) {
198
311
  const pending = this.pending.get(message.id);
199
312
  this.pending.delete(message.id);
@@ -277,8 +390,13 @@ class CdpSession {
277
390
 
278
391
  async function withTarget(args, callback) {
279
392
  const target = await chooseTarget(args);
393
+ safePageUrl(target.url);
280
394
  const session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
281
- try { return await callback(session, target); } finally { session.close(); }
395
+ try {
396
+ const currentUrl = await evaluate(session, 'location.href').catch(() => target.url);
397
+ safePageUrl(currentUrl);
398
+ return await callback(session, target);
399
+ } finally { session.close(); }
282
400
  }
283
401
 
284
402
  async function evaluate(session, expression, awaitPromise = true) {
@@ -338,8 +456,9 @@ async function waitForHistoryReady(session, expectedUrl, timeoutMs) {
338
456
  }
339
457
 
340
458
  export async function browserNavigate(args = {}) {
341
- const url = optionalString(args.url);
342
- const action = requireEnum(args.action || (url ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
459
+ const requestedUrl = optionalString(args.url);
460
+ const action = requireEnum(args.action || (requestedUrl ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
461
+ const url = ['url','new_tab'].includes(action) ? safeBrowserUrl(requestedUrl) : null;
343
462
  if (['url','new_tab'].includes(action) && !url) throw new Error('url is required when action=url or new_tab');
344
463
  if (action === 'new_tab') {
345
464
  await ensureBrowserEndpoint(args.endpoint);
@@ -347,7 +466,7 @@ export async function browserNavigate(args = {}) {
347
466
  return jsonResult({
348
467
  target_id:created?.id || null,
349
468
  action,
350
- url:created?.url || url,
469
+ url:created?.url ? safePageUrl(created.url) : url,
351
470
  title:created?.title || '',
352
471
  });
353
472
  }
@@ -372,7 +491,7 @@ export async function browserNavigate(args = {}) {
372
491
  const nextIndex = action === 'back' ? current - 1 : current + 1;
373
492
  const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
374
493
  if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
375
- historyUrl = entry.url || null;
494
+ historyUrl = entry.url ? safePageUrl(entry.url, 'history URL') : null;
376
495
  await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
377
496
  }
378
497
  if (loaded) await loaded;
@@ -380,6 +499,7 @@ export async function browserNavigate(args = {}) {
380
499
  await waitForHistoryReady(session, historyUrl, timeoutMs);
381
500
  }
382
501
  const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
502
+ if (currentUrl) safePageUrl(currentUrl);
383
503
  return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
384
504
  });
385
505
  }
@@ -701,6 +821,7 @@ export async function browserWait(args = {}) {
701
821
  const idleMs = clamp(args.idle_ms, 500, 100, 10_000);
702
822
  const wanted = optionalString(args.selector || args.text || args.value || args.expression);
703
823
  if (['selector','text','url_contains','expression'].includes(condition) && !wanted) throw new Error('selector/text/value/expression is required for this condition');
824
+ if (condition === 'expression') safeEvaluationExpression(wanted);
704
825
  return withTarget(args, async (session, target) => {
705
826
  const started = Date.now();
706
827
  const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
@@ -770,8 +891,7 @@ export async function browserWait(args = {}) {
770
891
  }
771
892
 
772
893
  export async function browserEvaluate(args) {
773
- const expression = optionalString(args.expression);
774
- if (!expression) throw new Error('expression is required');
894
+ const expression = safeEvaluationExpression(args.expression);
775
895
  return withTarget(args, async (session, target) => jsonResult({ target_id: target.id, value: await evaluate(session, expression, args.await_promise !== false) }));
776
896
  }
777
897
 
@@ -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