@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 +10 -2
- package/package.json +1 -1
- package/src/config.mjs +12 -2
- package/src/documents.mjs +7 -0
- package/src/extended/browser.mjs +134 -14
- package/src/extended/desktop-linux.mjs +80 -15
- package/src/extended/documents.mjs +107 -21
- package/src/helpers/wayland-eis-helper.py +30 -3
- package/src/policy.mjs +42 -9
- package/src/tools/files.mjs +505 -40
- package/src/tools/terminal.mjs +4 -2
- package/src/util.mjs +7 -1
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
|
|
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
|
-
-
|
|
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
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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/extended/browser.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
)
|
|
160
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
342
|
-
const action = requireEnum(args.action || (
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
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
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
|
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:
|
|
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
|
|