@remcp/runtime 0.2.173 → 0.2.174
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,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.174 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
- Preserve the 83 runtime operations and existing schemas while separating guarded browser CDP security policy and diagnostics recording backends.
|
|
6
|
+
- Retain browser-origin, DNS/SSRF checks, platform protections and output limits without widening device permissions.
|
|
7
|
+
- Publish alongside the refactored client/host at the same version for package compatibility.
|
|
8
|
+
|
|
3
9
|
## 0.2.173 — 2026-10-22
|
|
4
10
|
|
|
5
11
|
- Return initial terminal output promptly after a bounded 175 ms grace when a long-running process writes without exiting.
|
package/package.json
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
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';
|
|
5
|
-
import { lookup } from 'node:dns/promises';
|
|
6
4
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
7
5
|
import { resolveSafePath } from '../util.mjs';
|
|
8
6
|
import { selectSafeBrowserLaunchProfile } from './browser-session-backup.mjs';
|
|
@@ -10,16 +8,14 @@ import { shouldRestoreBrowserSession } from './browser-session-directory.mjs';
|
|
|
10
8
|
import { shareBrowserDiscovery } from './browser-discovery-flight.mjs';
|
|
11
9
|
import { observeBrowserLaunchExit, browserLaunchExitHint } from './browser-launch-lifecycle.mjs';
|
|
12
10
|
import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
|
|
11
|
+
import {
|
|
12
|
+
endpointUrl, endpointAuthority, isLoopbackHost, normalizedHost,
|
|
13
|
+
safePageUrl, assertResolvedPageUrl, productionBrowserPolicyReady,
|
|
14
|
+
} from './browser-policy.mjs';
|
|
15
|
+
export * from './browser-policy.mjs';
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
|
|
17
17
|
export const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
|
|
18
18
|
export const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
19
|
-
export const MAX_EVALUATION_BYTES = 256 * 1024;
|
|
20
|
-
export const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
|
|
21
|
-
export const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob|wss?):/i;
|
|
22
|
-
export const BLOCKED_BROWSER_NETWORK_API = /\b(?:fetch|WebSocket|EventSource|XMLHttpRequest|sendBeacon)\b/;
|
|
23
19
|
export const PRODUCTION_SESSION_IDLE_MS = 10 * 60 * 1000;
|
|
24
20
|
export const productionBrowserSessions = new Map();
|
|
25
21
|
|
|
@@ -133,185 +129,6 @@ export async function browserControlAvailable(endpoint, timeoutMs = 500) {
|
|
|
133
129
|
return browserAutoLaunchAvailable();
|
|
134
130
|
}
|
|
135
131
|
|
|
136
|
-
export function boundedText(value, max = 1000) {
|
|
137
|
-
const text = String(value ?? '');
|
|
138
|
-
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function normalizedHost(hostname) {
|
|
142
|
-
return String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function isLoopbackHost(hostname) {
|
|
146
|
-
const host = normalizedHost(hostname);
|
|
147
|
-
return ['localhost', '127.0.0.1', '::1'].includes(host);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function localBrowserNavigationAllowed() {
|
|
151
|
-
if (process.env.NODE_ENV === 'production') return false;
|
|
152
|
-
return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
|
|
153
|
-
.some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function isPrivateIpv4(host) {
|
|
157
|
-
if (isIP(host) !== 4) return false;
|
|
158
|
-
const parts = host.split('.').map(Number);
|
|
159
|
-
const [a, b, c] = parts;
|
|
160
|
-
return a === 0
|
|
161
|
-
|| a === 10
|
|
162
|
-
|| a === 127
|
|
163
|
-
|| (a === 100 && b >= 64 && b <= 127)
|
|
164
|
-
|| (a === 169 && b === 254)
|
|
165
|
-
|| (a === 172 && b >= 16 && b <= 31)
|
|
166
|
-
|| (a === 192 && b === 0 && c === 0)
|
|
167
|
-
|| (a === 192 && b === 0 && c === 2)
|
|
168
|
-
|| (a === 192 && b === 168)
|
|
169
|
-
|| (a === 198 && (b === 18 || b === 19))
|
|
170
|
-
|| (a === 198 && b === 51 && c === 100)
|
|
171
|
-
|| (a === 203 && b === 0 && c === 113)
|
|
172
|
-
|| a >= 224;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function isPrivateIpv6(host) {
|
|
176
|
-
if (isIP(host) !== 6) return false;
|
|
177
|
-
if (host === '::' || host === '::1') return true;
|
|
178
|
-
if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith('ff')) return true;
|
|
179
|
-
const mapped = host.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
180
|
-
if (mapped) {
|
|
181
|
-
const high = Number.parseInt(mapped[1], 16);
|
|
182
|
-
const low = Number.parseInt(mapped[2], 16);
|
|
183
|
-
return isPrivateIpv4([high >> 8, high & 255, low >> 8, low & 255].join('.'));
|
|
184
|
-
}
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function isPrivateBrowserHost(hostname) {
|
|
189
|
-
const host = normalizedHost(hostname);
|
|
190
|
-
if (!host) return true;
|
|
191
|
-
if (isPrivateIpv4(host) || isPrivateIpv6(host)) return true;
|
|
192
|
-
return host === 'localhost'
|
|
193
|
-
|| host.endsWith('.localhost')
|
|
194
|
-
|| host.endsWith('.local')
|
|
195
|
-
|| host.endsWith('.internal')
|
|
196
|
-
|| host.endsWith('.home.arpa')
|
|
197
|
-
|| host.endsWith('.lan')
|
|
198
|
-
|| host === 'localhost.localdomain'
|
|
199
|
-
|| host === 'ip6-localhost'
|
|
200
|
-
|| host === 'ip6-loopback';
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function endpointUrl(value) {
|
|
204
|
-
if (value != null && typeof value !== 'string' && !(value instanceof URL)) {
|
|
205
|
-
throw new Error('endpoint must be a valid local http(s) URL');
|
|
206
|
-
}
|
|
207
|
-
let url;
|
|
208
|
-
try {
|
|
209
|
-
const provided = value instanceof URL ? value.href : optionalString(value);
|
|
210
|
-
url = new URL(provided || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT);
|
|
211
|
-
} catch { throw new Error('endpoint must be a valid local http(s) URL'); }
|
|
212
|
-
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
|
|
213
|
-
if (!isLoopbackHost(url.hostname)) throw new Error('Browser CDP endpoint must be loopback-only');
|
|
214
|
-
if (url.username || url.password) throw new Error('Browser CDP endpoint must not contain credentials');
|
|
215
|
-
return url;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export function endpointAuthority(url) {
|
|
219
|
-
const port = url.port || ((url.protocol === 'https:' || url.protocol === 'wss:') ? '443' : '80');
|
|
220
|
-
return `${normalizedHost(url.hostname)}:${port}`;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function safeBrowserUrl(value, field = 'url') {
|
|
224
|
-
let url;
|
|
225
|
-
try { url = new URL(String(value || '')); }
|
|
226
|
-
catch { throw new Error(`${field} must be a valid absolute URL`); }
|
|
227
|
-
if (!BROWSER_PROTOCOLS.has(url.protocol)) throw new Error(`${field} must use http or https`);
|
|
228
|
-
if (url.username || url.password) throw new Error(`${field} must not embed credentials`);
|
|
229
|
-
if (!localBrowserNavigationAllowed() && isPrivateBrowserHost(url.hostname)) {
|
|
230
|
-
throw new Error(`${field} must not target loopback, private, or link-local addresses`);
|
|
231
|
-
}
|
|
232
|
-
return url.href;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function safePageUrl(value, field = 'browser page URL') {
|
|
236
|
-
if (String(value || '') === 'about:blank') return value;
|
|
237
|
-
return safeBrowserUrl(value, field);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function configuredBrowserHosts() {
|
|
241
|
-
return String(process.env.REMCP_BROWSER_ALLOWED_HOSTS || '')
|
|
242
|
-
.split(/[,\s]+/)
|
|
243
|
-
.map(normalizedHost)
|
|
244
|
-
.filter(Boolean);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export function browserHostAllowlisted(host) {
|
|
248
|
-
return configuredBrowserHosts().some(pattern => pattern === host
|
|
249
|
-
|| (pattern.startsWith('*.') && host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1));
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export function browserDnsRebindingExplicitlyAllowed() {
|
|
253
|
-
return ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_DNS_REBIND || '').trim().toLowerCase());
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
export function productionBrowserPolicyReady() {
|
|
257
|
-
const hosts = configuredBrowserHosts();
|
|
258
|
-
return (hosts.length > 0 && hosts.some(isIP))
|
|
259
|
-
|| (browserDnsRebindingExplicitlyAllowed() && hosts.length > 0);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export async function assertResolvedBrowserUrl(value, field = 'browser URL') {
|
|
263
|
-
const safe = safeBrowserUrl(value, field);
|
|
264
|
-
if (localBrowserNavigationAllowed() && process.env.NODE_ENV !== 'production') return safe;
|
|
265
|
-
const host = normalizedHost(new URL(safe).hostname);
|
|
266
|
-
if (process.env.NODE_ENV === 'production' && !browserHostAllowlisted(host)) {
|
|
267
|
-
throw new Error(`${field} host is not in REMCP_BROWSER_ALLOWED_HOSTS`);
|
|
268
|
-
}
|
|
269
|
-
if (isIP(host) || (host.endsWith('.test') && process.env.NODE_ENV !== 'production')) return safe;
|
|
270
|
-
if (process.env.NODE_ENV === 'production' && !isIP(host) && !browserDnsRebindingExplicitlyAllowed()) {
|
|
271
|
-
throw new Error(`${field} requires an IP-literal allowlist in production; set REMCP_BROWSER_ALLOW_DNS_REBIND=1 only with a DNS/egress boundary`);
|
|
272
|
-
}
|
|
273
|
-
let addresses;
|
|
274
|
-
try { addresses = await lookup(host, { all:true, verbatim:true }); }
|
|
275
|
-
catch (error) { throw new Error(`${field} host could not be resolved safely: ${error instanceof Error ? error.message : String(error)}`); }
|
|
276
|
-
if (addresses.some(address => isPrivateBrowserHost(address.address))) {
|
|
277
|
-
throw new Error(`${field} must not resolve to loopback, private, or link-local addresses`);
|
|
278
|
-
}
|
|
279
|
-
return safe;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export async function assertResolvedPageUrl(value, field = 'browser page URL') {
|
|
283
|
-
if (String(value || '') === 'about:blank') return value;
|
|
284
|
-
return assertResolvedBrowserUrl(value, field);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
export function expressionHasPrivateDestination(expression) {
|
|
288
|
-
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
289
|
-
return urls.some(value => {
|
|
290
|
-
try { return isPrivateBrowserHost(new URL(value).hostname); }
|
|
291
|
-
catch { return false; }
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
export function safeEvaluationExpression(value) {
|
|
296
|
-
const expression = String(value || '');
|
|
297
|
-
if (!expression.trim()) throw new Error('expression is required');
|
|
298
|
-
if (expression.includes('\0') || Buffer.byteLength(expression, 'utf8') > MAX_EVALUATION_BYTES) {
|
|
299
|
-
throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
|
|
300
|
-
}
|
|
301
|
-
if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
|
|
302
|
-
if (!localBrowserNavigationAllowed() && (expressionHasPrivateDestination(expression) || BLOCKED_BROWSER_NETWORK_API.test(expression))) {
|
|
303
|
-
throw new Error('expression contains a blocked private browser destination or network API');
|
|
304
|
-
}
|
|
305
|
-
return expression;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export async function assertSafeEvaluationExpression(value) {
|
|
309
|
-
const expression = safeEvaluationExpression(value);
|
|
310
|
-
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
311
|
-
await Promise.all(urls.map(url => assertResolvedBrowserUrl(url, 'expression destination')));
|
|
312
|
-
return expression;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
132
|
export async function requestJson(pathname, endpoint, options = {}) {
|
|
316
133
|
const base = endpointUrl(endpoint);
|
|
317
134
|
const url = new URL(pathname, base);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Browser navigation, CDP-origin and evaluation policies. No CDP transport or session state.
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import { lookup } from 'node:dns/promises';
|
|
5
|
+
import { optionalString } from './common.mjs';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
|
|
8
|
+
export const MAX_EVALUATION_BYTES = 256 * 1024;
|
|
9
|
+
export const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
|
|
10
|
+
export const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob|wss?):/i;
|
|
11
|
+
export const BLOCKED_BROWSER_NETWORK_API = /\b(?:fetch|WebSocket|EventSource|XMLHttpRequest|sendBeacon)\b/;
|
|
12
|
+
|
|
13
|
+
export function boundedText(value, max = 1000) {
|
|
14
|
+
const text = String(value ?? '');
|
|
15
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizedHost(hostname) {
|
|
19
|
+
return String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isLoopbackHost(hostname) {
|
|
23
|
+
const host = normalizedHost(hostname);
|
|
24
|
+
return ['localhost', '127.0.0.1', '::1'].includes(host);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function localBrowserNavigationAllowed() {
|
|
28
|
+
if (process.env.NODE_ENV === 'production') return false;
|
|
29
|
+
return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
|
|
30
|
+
.some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isPrivateIpv4(host) {
|
|
34
|
+
if (isIP(host) !== 4) return false;
|
|
35
|
+
const parts = host.split('.').map(Number);
|
|
36
|
+
const [a, b, c] = parts;
|
|
37
|
+
return a === 0
|
|
38
|
+
|| a === 10
|
|
39
|
+
|| a === 127
|
|
40
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
41
|
+
|| (a === 169 && b === 254)
|
|
42
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
43
|
+
|| (a === 192 && b === 0 && c === 0)
|
|
44
|
+
|| (a === 192 && b === 0 && c === 2)
|
|
45
|
+
|| (a === 192 && b === 168)
|
|
46
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
47
|
+
|| (a === 198 && b === 51 && c === 100)
|
|
48
|
+
|| (a === 203 && b === 0 && c === 113)
|
|
49
|
+
|| a >= 224;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isPrivateIpv6(host) {
|
|
53
|
+
if (isIP(host) !== 6) return false;
|
|
54
|
+
if (host === '::' || host === '::1') return true;
|
|
55
|
+
if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith('ff')) return true;
|
|
56
|
+
const mapped = host.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
57
|
+
if (mapped) {
|
|
58
|
+
const high = Number.parseInt(mapped[1], 16);
|
|
59
|
+
const low = Number.parseInt(mapped[2], 16);
|
|
60
|
+
return isPrivateIpv4([high >> 8, high & 255, low >> 8, low & 255].join('.'));
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isPrivateBrowserHost(hostname) {
|
|
66
|
+
const host = normalizedHost(hostname);
|
|
67
|
+
if (!host) return true;
|
|
68
|
+
if (isPrivateIpv4(host) || isPrivateIpv6(host)) return true;
|
|
69
|
+
return host === 'localhost'
|
|
70
|
+
|| host.endsWith('.localhost')
|
|
71
|
+
|| host.endsWith('.local')
|
|
72
|
+
|| host.endsWith('.internal')
|
|
73
|
+
|| host.endsWith('.home.arpa')
|
|
74
|
+
|| host.endsWith('.lan')
|
|
75
|
+
|| host === 'localhost.localdomain'
|
|
76
|
+
|| host === 'ip6-localhost'
|
|
77
|
+
|| host === 'ip6-loopback';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function endpointUrl(value) {
|
|
81
|
+
if (value != null && typeof value !== 'string' && !(value instanceof URL)) {
|
|
82
|
+
throw new Error('endpoint must be a valid local http(s) URL');
|
|
83
|
+
}
|
|
84
|
+
let url;
|
|
85
|
+
try {
|
|
86
|
+
const provided = value instanceof URL ? value.href : optionalString(value);
|
|
87
|
+
url = new URL(provided || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT);
|
|
88
|
+
} catch { throw new Error('endpoint must be a valid local http(s) URL'); }
|
|
89
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
|
|
90
|
+
if (!isLoopbackHost(url.hostname)) throw new Error('Browser CDP endpoint must be loopback-only');
|
|
91
|
+
if (url.username || url.password) throw new Error('Browser CDP endpoint must not contain credentials');
|
|
92
|
+
return url;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function endpointAuthority(url) {
|
|
96
|
+
const port = url.port || ((url.protocol === 'https:' || url.protocol === 'wss:') ? '443' : '80');
|
|
97
|
+
return `${normalizedHost(url.hostname)}:${port}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function safeBrowserUrl(value, field = 'url') {
|
|
101
|
+
let url;
|
|
102
|
+
try { url = new URL(String(value || '')); }
|
|
103
|
+
catch { throw new Error(`${field} must be a valid absolute URL`); }
|
|
104
|
+
if (!BROWSER_PROTOCOLS.has(url.protocol)) throw new Error(`${field} must use http or https`);
|
|
105
|
+
if (url.username || url.password) throw new Error(`${field} must not embed credentials`);
|
|
106
|
+
if (!localBrowserNavigationAllowed() && isPrivateBrowserHost(url.hostname)) {
|
|
107
|
+
throw new Error(`${field} must not target loopback, private, or link-local addresses`);
|
|
108
|
+
}
|
|
109
|
+
return url.href;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function safePageUrl(value, field = 'browser page URL') {
|
|
113
|
+
if (String(value || '') === 'about:blank') return value;
|
|
114
|
+
return safeBrowserUrl(value, field);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function configuredBrowserHosts() {
|
|
118
|
+
return String(process.env.REMCP_BROWSER_ALLOWED_HOSTS || '')
|
|
119
|
+
.split(/[,\s]+/)
|
|
120
|
+
.map(normalizedHost)
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function browserHostAllowlisted(host) {
|
|
125
|
+
return configuredBrowserHosts().some(pattern => pattern === host
|
|
126
|
+
|| (pattern.startsWith('*.') && host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function browserDnsRebindingExplicitlyAllowed() {
|
|
130
|
+
return ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_DNS_REBIND || '').trim().toLowerCase());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function productionBrowserPolicyReady() {
|
|
134
|
+
const hosts = configuredBrowserHosts();
|
|
135
|
+
return (hosts.length > 0 && hosts.some(isIP))
|
|
136
|
+
|| (browserDnsRebindingExplicitlyAllowed() && hosts.length > 0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function assertResolvedBrowserUrl(value, field = 'browser URL') {
|
|
140
|
+
const safe = safeBrowserUrl(value, field);
|
|
141
|
+
if (localBrowserNavigationAllowed() && process.env.NODE_ENV !== 'production') return safe;
|
|
142
|
+
const host = normalizedHost(new URL(safe).hostname);
|
|
143
|
+
if (process.env.NODE_ENV === 'production' && !browserHostAllowlisted(host)) {
|
|
144
|
+
throw new Error(`${field} host is not in REMCP_BROWSER_ALLOWED_HOSTS`);
|
|
145
|
+
}
|
|
146
|
+
if (isIP(host) || (host.endsWith('.test') && process.env.NODE_ENV !== 'production')) return safe;
|
|
147
|
+
if (process.env.NODE_ENV === 'production' && !isIP(host) && !browserDnsRebindingExplicitlyAllowed()) {
|
|
148
|
+
throw new Error(`${field} requires an IP-literal allowlist in production; set REMCP_BROWSER_ALLOW_DNS_REBIND=1 only with a DNS/egress boundary`);
|
|
149
|
+
}
|
|
150
|
+
let addresses;
|
|
151
|
+
try { addresses = await lookup(host, { all:true, verbatim:true }); }
|
|
152
|
+
catch (error) { throw new Error(`${field} host could not be resolved safely: ${error instanceof Error ? error.message : String(error)}`); }
|
|
153
|
+
if (addresses.some(address => isPrivateBrowserHost(address.address))) {
|
|
154
|
+
throw new Error(`${field} must not resolve to loopback, private, or link-local addresses`);
|
|
155
|
+
}
|
|
156
|
+
return safe;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function assertResolvedPageUrl(value, field = 'browser page URL') {
|
|
160
|
+
if (String(value || '') === 'about:blank') return value;
|
|
161
|
+
return assertResolvedBrowserUrl(value, field);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function expressionHasPrivateDestination(expression) {
|
|
165
|
+
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
166
|
+
return urls.some(value => {
|
|
167
|
+
try { return isPrivateBrowserHost(new URL(value).hostname); }
|
|
168
|
+
catch { return false; }
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function safeEvaluationExpression(value) {
|
|
173
|
+
const expression = String(value || '');
|
|
174
|
+
if (!expression.trim()) throw new Error('expression is required');
|
|
175
|
+
if (expression.includes('\0') || Buffer.byteLength(expression, 'utf8') > MAX_EVALUATION_BYTES) {
|
|
176
|
+
throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
|
|
177
|
+
}
|
|
178
|
+
if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
|
|
179
|
+
if (!localBrowserNavigationAllowed() && (expressionHasPrivateDestination(expression) || BLOCKED_BROWSER_NETWORK_API.test(expression))) {
|
|
180
|
+
throw new Error('expression contains a blocked private browser destination or network API');
|
|
181
|
+
}
|
|
182
|
+
return expression;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function assertSafeEvaluationExpression(value) {
|
|
186
|
+
const expression = safeEvaluationExpression(value);
|
|
187
|
+
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
188
|
+
await Promise.all(urls.map(url => assertResolvedBrowserUrl(url, 'expression destination')));
|
|
189
|
+
return expression;
|
|
190
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Screen recording backends and safe output handling, independent of system diagnostics.
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { constants, existsSync } from 'node:fs';
|
|
7
|
+
import { lstat, mkdtemp, open, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { isWaylandSession } from '../screenshot-portal.mjs';
|
|
9
|
+
import { openDirectoryPath } from '../tools/files.mjs';
|
|
10
|
+
import { resolveSafePath } from '../util.mjs';
|
|
11
|
+
import { clamp, commandExists, jsonResult, runFile, unavailable } from './common.mjs';
|
|
12
|
+
|
|
13
|
+
export function resolveRecordScreenFfmpeg({
|
|
14
|
+
platform = process.platform,
|
|
15
|
+
commandExistsFn = commandExists,
|
|
16
|
+
existsSyncFn = existsSync,
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (commandExistsFn('ffmpeg')) return 'ffmpeg';
|
|
19
|
+
if (platform === 'darwin') {
|
|
20
|
+
for (const candidate of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']) {
|
|
21
|
+
if (existsSyncFn(candidate)) return candidate;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseAvfoundationScreenInput(output) {
|
|
28
|
+
for (const line of String(output || '').split(/\r?\n/)) {
|
|
29
|
+
const match = line.match(/\[(\d+)\]\s+Capture screen(?:\s+\d+)?\s*$/i);
|
|
30
|
+
if (match) return match[1];
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function avfoundationScreenInput(ffmpeg) {
|
|
36
|
+
const result = await runFile(ffmpeg, [
|
|
37
|
+
'-hide_banner',
|
|
38
|
+
'-f','avfoundation',
|
|
39
|
+
'-list_devices','true',
|
|
40
|
+
'-i','',
|
|
41
|
+
], { label:'AVFoundation device inventory', timeout:10_000, allowFailure:true });
|
|
42
|
+
const index = parseAvfoundationScreenInput(`${result.stderr || ''}\n${result.stdout || ''}`);
|
|
43
|
+
if (!index) unavailable('Screen recording', 'ffmpeg could not find an AVFoundation Capture screen input');
|
|
44
|
+
return index;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function gnomeScreencastCandidate({
|
|
48
|
+
platform = process.platform,
|
|
49
|
+
wayland = platform === 'linux' ? isWaylandSession() : false,
|
|
50
|
+
desktop = process.env.XDG_CURRENT_DESKTOP || '',
|
|
51
|
+
gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
|
|
52
|
+
sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
|
|
53
|
+
commandExistsFn = commandExists,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const gnomeSession = /gnome/i.test(String(desktop)) || Boolean(String(gnomeSessionMode).trim());
|
|
56
|
+
return platform === 'linux'
|
|
57
|
+
&& wayland
|
|
58
|
+
&& gnomeSession
|
|
59
|
+
&& Boolean(String(sessionBus).trim())
|
|
60
|
+
&& commandExistsFn('gjs');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function recordScreenBackend({
|
|
64
|
+
platform = process.platform,
|
|
65
|
+
wayland = platform === 'linux' ? isWaylandSession() : false,
|
|
66
|
+
display = process.env.DISPLAY || '',
|
|
67
|
+
desktop = process.env.XDG_CURRENT_DESKTOP || '',
|
|
68
|
+
gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
|
|
69
|
+
sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
|
|
70
|
+
gnomeSupported = false,
|
|
71
|
+
commandExistsFn = commandExists,
|
|
72
|
+
existsSyncFn = existsSync,
|
|
73
|
+
} = {}) {
|
|
74
|
+
if (platform === 'linux') {
|
|
75
|
+
if (wayland) {
|
|
76
|
+
if (gnomeSupported && gnomeScreencastCandidate({
|
|
77
|
+
platform,
|
|
78
|
+
wayland,
|
|
79
|
+
desktop,
|
|
80
|
+
gnomeSessionMode,
|
|
81
|
+
sessionBus,
|
|
82
|
+
commandExistsFn,
|
|
83
|
+
})) return 'gnome-shell';
|
|
84
|
+
if (commandExistsFn('wf-recorder') && commandExistsFn('timeout')) return 'wf-recorder';
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return Boolean(String(display).trim()) && commandExistsFn('ffmpeg') ? 'ffmpeg-x11' : null;
|
|
88
|
+
}
|
|
89
|
+
if (platform === 'darwin') {
|
|
90
|
+
return resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }) ? 'ffmpeg-avfoundation' : null;
|
|
91
|
+
}
|
|
92
|
+
if (platform === 'win32') {
|
|
93
|
+
return resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }) ? 'ffmpeg-gdigrab' : null;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function recordScreenAvailable({
|
|
99
|
+
platform = process.platform,
|
|
100
|
+
wayland = platform === 'linux' ? isWaylandSession() : false,
|
|
101
|
+
display = process.env.DISPLAY || '',
|
|
102
|
+
desktop = process.env.XDG_CURRENT_DESKTOP || '',
|
|
103
|
+
gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
|
|
104
|
+
sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
|
|
105
|
+
gnomeSupported = false,
|
|
106
|
+
commandExistsFn = commandExists,
|
|
107
|
+
existsSyncFn = existsSync,
|
|
108
|
+
} = {}) {
|
|
109
|
+
return Boolean(recordScreenBackend({
|
|
110
|
+
platform,
|
|
111
|
+
wayland,
|
|
112
|
+
display,
|
|
113
|
+
desktop,
|
|
114
|
+
gnomeSessionMode,
|
|
115
|
+
sessionBus,
|
|
116
|
+
gnomeSupported,
|
|
117
|
+
commandExistsFn,
|
|
118
|
+
existsSyncFn,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const GNOME_SCREENCAST_SUPPORT_HELPER = `const {Gio, GLib} = imports.gi;
|
|
123
|
+
const bus = Gio.bus_get_sync(Gio.BusType.SESSION, null);
|
|
124
|
+
const value = bus.call_sync(
|
|
125
|
+
'org.gnome.Shell.Screencast',
|
|
126
|
+
'/org/gnome/Shell/Screencast',
|
|
127
|
+
'org.freedesktop.DBus.Properties',
|
|
128
|
+
'Get',
|
|
129
|
+
new GLib.Variant('(ss)', ['org.gnome.Shell.Screencast', 'ScreencastSupported']),
|
|
130
|
+
new GLib.VariantType('(v)'),
|
|
131
|
+
Gio.DBusCallFlags.NONE,
|
|
132
|
+
3000,
|
|
133
|
+
null
|
|
134
|
+
).deepUnpack();
|
|
135
|
+
print(value[0].deepUnpack() ? 'true' : 'false');
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
let gnomeScreencastSupportCache = { key:'', value:false, expiresAt:0 };
|
|
139
|
+
let gnomeScreencastSupportProbe = { key:'', promise:null };
|
|
140
|
+
|
|
141
|
+
export async function gnomeScreencastSupported({
|
|
142
|
+
platform = process.platform,
|
|
143
|
+
wayland = platform === 'linux' ? isWaylandSession() : false,
|
|
144
|
+
desktop = process.env.XDG_CURRENT_DESKTOP || '',
|
|
145
|
+
gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
|
|
146
|
+
sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
|
|
147
|
+
commandExistsFn = commandExists,
|
|
148
|
+
runFileFn = runFile,
|
|
149
|
+
cacheMs = 15_000,
|
|
150
|
+
nowFn = Date.now,
|
|
151
|
+
} = {}) {
|
|
152
|
+
if (!gnomeScreencastCandidate({
|
|
153
|
+
platform,
|
|
154
|
+
wayland,
|
|
155
|
+
desktop,
|
|
156
|
+
gnomeSessionMode,
|
|
157
|
+
sessionBus,
|
|
158
|
+
commandExistsFn,
|
|
159
|
+
})) return false;
|
|
160
|
+
|
|
161
|
+
const key = [platform, wayland ? 'wayland' : '', desktop, gnomeSessionMode, sessionBus].join('|');
|
|
162
|
+
const now = nowFn();
|
|
163
|
+
if (cacheMs > 0 && gnomeScreencastSupportCache.key === key && gnomeScreencastSupportCache.expiresAt > now) {
|
|
164
|
+
return gnomeScreencastSupportCache.value;
|
|
165
|
+
}
|
|
166
|
+
if (gnomeScreencastSupportProbe.key === key && gnomeScreencastSupportProbe.promise) {
|
|
167
|
+
return gnomeScreencastSupportProbe.promise;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const promise = (async () => {
|
|
171
|
+
let value = false;
|
|
172
|
+
try {
|
|
173
|
+
const result = await runFileFn('gjs', ['-c', GNOME_SCREENCAST_SUPPORT_HELPER], {
|
|
174
|
+
label:'GNOME Shell screencast capability probe',
|
|
175
|
+
timeout:5000,
|
|
176
|
+
maxBuffer:64 * 1024,
|
|
177
|
+
allowFailure:true,
|
|
178
|
+
});
|
|
179
|
+
value = Number(result.code) === 0 && String(result.stdout || '').trim() === 'true';
|
|
180
|
+
} catch {
|
|
181
|
+
value = false;
|
|
182
|
+
}
|
|
183
|
+
gnomeScreencastSupportCache = {
|
|
184
|
+
key,
|
|
185
|
+
value,
|
|
186
|
+
expiresAt:nowFn() + Math.max(0, cacheMs),
|
|
187
|
+
};
|
|
188
|
+
return value;
|
|
189
|
+
})();
|
|
190
|
+
|
|
191
|
+
gnomeScreencastSupportProbe = { key, promise };
|
|
192
|
+
try {
|
|
193
|
+
return await promise;
|
|
194
|
+
} finally {
|
|
195
|
+
if (gnomeScreencastSupportProbe.key === key && gnomeScreencastSupportProbe.promise === promise) {
|
|
196
|
+
gnomeScreencastSupportProbe = { key:'', promise:null };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const GNOME_SCREENCAST_HELPER = `const {Gio, GLib} = imports.gi;
|
|
202
|
+
const [destination, secondsRaw, fpsRaw] = ARGV;
|
|
203
|
+
const seconds = Number(secondsRaw);
|
|
204
|
+
const fps = Number(fpsRaw);
|
|
205
|
+
const bus = Gio.bus_get_sync(Gio.BusType.SESSION, null);
|
|
206
|
+
const destinationName = 'org.gnome.Shell.Screencast';
|
|
207
|
+
const objectPath = '/org/gnome/Shell/Screencast';
|
|
208
|
+
const interfaceName = 'org.gnome.Shell.Screencast';
|
|
209
|
+
const property = bus.call_sync(
|
|
210
|
+
destinationName,
|
|
211
|
+
objectPath,
|
|
212
|
+
'org.freedesktop.DBus.Properties',
|
|
213
|
+
'Get',
|
|
214
|
+
new GLib.Variant('(ss)', [interfaceName, 'ScreencastSupported']),
|
|
215
|
+
new GLib.VariantType('(v)'),
|
|
216
|
+
Gio.DBusCallFlags.NONE,
|
|
217
|
+
5000,
|
|
218
|
+
null
|
|
219
|
+
).deepUnpack();
|
|
220
|
+
if (!property[0].deepUnpack()) throw new Error('GNOME Shell reports ScreencastSupported=false');
|
|
221
|
+
const options = {
|
|
222
|
+
'draw-cursor': new GLib.Variant('b', true),
|
|
223
|
+
'framerate': new GLib.Variant('i', fps),
|
|
224
|
+
};
|
|
225
|
+
const started = bus.call_sync(
|
|
226
|
+
destinationName,
|
|
227
|
+
objectPath,
|
|
228
|
+
interfaceName,
|
|
229
|
+
'Screencast',
|
|
230
|
+
new GLib.Variant('(sa{sv})', [destination, options]),
|
|
231
|
+
new GLib.VariantType('(bs)'),
|
|
232
|
+
Gio.DBusCallFlags.NONE,
|
|
233
|
+
10000,
|
|
234
|
+
null
|
|
235
|
+
).deepUnpack();
|
|
236
|
+
if (!started[0]) throw new Error('GNOME Shell refused to start screencast');
|
|
237
|
+
GLib.usleep(Math.round(seconds * 1000000));
|
|
238
|
+
const stopped = bus.call_sync(
|
|
239
|
+
destinationName,
|
|
240
|
+
objectPath,
|
|
241
|
+
interfaceName,
|
|
242
|
+
'StopScreencast',
|
|
243
|
+
null,
|
|
244
|
+
new GLib.VariantType('(b)'),
|
|
245
|
+
Gio.DBusCallFlags.NONE,
|
|
246
|
+
10000,
|
|
247
|
+
null
|
|
248
|
+
).deepUnpack();
|
|
249
|
+
if (!stopped[0]) throw new Error('GNOME Shell did not stop screencast cleanly');
|
|
250
|
+
print(JSON.stringify({ destination:started[1], stopped:stopped[0] }));
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
async function recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps }) {
|
|
254
|
+
const helper = path.join(stagingDirectory, 'gnome-screencast.js');
|
|
255
|
+
await writeFile(helper, GNOME_SCREENCAST_HELPER, { mode:0o600 });
|
|
256
|
+
await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
|
|
257
|
+
label:'GNOME Shell screen recording',
|
|
258
|
+
timeout:(seconds + 20) * 1000,
|
|
259
|
+
maxBuffer:2 * 1024 * 1024,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function validateRecordingDestinationFormat(destination, format) {
|
|
264
|
+
if (!destination) return;
|
|
265
|
+
const extension = path.extname(destination).slice(1).toLowerCase();
|
|
266
|
+
if (extension && extension !== format) {
|
|
267
|
+
throw new Error(`Screen recording backend produces .${format}; destination must use .${format} or omit the extension`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function openRecordingParent(destination) {
|
|
272
|
+
return openDirectoryPath(path.dirname(destination));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function copyRecordingFile(source, destination) {
|
|
276
|
+
let input;
|
|
277
|
+
let output;
|
|
278
|
+
let parent;
|
|
279
|
+
let temporary;
|
|
280
|
+
try {
|
|
281
|
+
input = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
282
|
+
const sourceInfo = await input.stat();
|
|
283
|
+
if (!sourceInfo.isFile()) throw new Error('Screen recording staging path is not a regular file');
|
|
284
|
+
parent = await openRecordingParent(destination);
|
|
285
|
+
const anchoredDestination = path.join(parent.anchor, path.basename(destination));
|
|
286
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
287
|
+
if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
|
|
288
|
+
if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
|
|
289
|
+
temporary = path.join(parent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
|
|
290
|
+
output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
291
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
292
|
+
let offset = 0;
|
|
293
|
+
for (;;) {
|
|
294
|
+
const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
|
|
295
|
+
if (!bytesRead) break;
|
|
296
|
+
let written = 0;
|
|
297
|
+
while (written < bytesRead) {
|
|
298
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
299
|
+
if (!result.bytesWritten) throw new Error('Could not finish writing the screen recording');
|
|
300
|
+
written += result.bytesWritten;
|
|
301
|
+
}
|
|
302
|
+
offset += bytesRead;
|
|
303
|
+
}
|
|
304
|
+
await output.sync();
|
|
305
|
+
await output.close();
|
|
306
|
+
output = null;
|
|
307
|
+
await rename(temporary, anchoredDestination);
|
|
308
|
+
temporary = null;
|
|
309
|
+
} finally {
|
|
310
|
+
if (output) await output.close().catch(() => {});
|
|
311
|
+
if (temporary) await unlink(temporary).catch(() => {});
|
|
312
|
+
if (input) await input.close().catch(() => {});
|
|
313
|
+
if (parent) await parent.close().catch(() => {});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function recordScreen(args) {
|
|
318
|
+
const seconds = clamp(args.duration_seconds, 5, 1, 120);
|
|
319
|
+
const fps = clamp(args.fps, 15, 1, 60);
|
|
320
|
+
const wayland = process.platform === 'linux' && isWaylandSession();
|
|
321
|
+
const gnomeSupported = wayland ? await gnomeScreencastSupported() : false;
|
|
322
|
+
const backend = recordScreenBackend({ platform:process.platform, wayland, gnomeSupported });
|
|
323
|
+
if (!backend) {
|
|
324
|
+
if (wayland) unavailable('Screen recording', 'GNOME Shell Screencast or wf-recorder with timeout is required on Wayland');
|
|
325
|
+
if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
|
|
326
|
+
if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
|
|
327
|
+
if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
|
|
328
|
+
unavailable('Screen recording', 'ffmpeg is required on X11');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const format = backend === 'gnome-shell' ? 'webm' : 'mp4';
|
|
332
|
+
const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
|
|
333
|
+
if (requestedDestination) {
|
|
334
|
+
validateRecordingDestinationFormat(requestedDestination, format);
|
|
335
|
+
const existing = await lstat(requestedDestination).catch(() => null);
|
|
336
|
+
if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
|
|
337
|
+
if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
|
|
341
|
+
const staged = path.join(stagingDirectory, `capture-${randomUUID()}.${format}`);
|
|
342
|
+
let keepStaging = false;
|
|
343
|
+
try {
|
|
344
|
+
if (backend === 'gnome-shell') {
|
|
345
|
+
await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
|
|
346
|
+
} else if (backend === 'wf-recorder') {
|
|
347
|
+
const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',staged,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
|
|
348
|
+
if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
|
|
349
|
+
} else {
|
|
350
|
+
const ffmpeg = resolveRecordScreenFfmpeg();
|
|
351
|
+
if (!ffmpeg) {
|
|
352
|
+
if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
|
|
353
|
+
if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
|
|
354
|
+
unavailable('Screen recording', 'ffmpeg is required on X11');
|
|
355
|
+
}
|
|
356
|
+
let argv;
|
|
357
|
+
if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
358
|
+
else if (process.platform === 'darwin') {
|
|
359
|
+
const input = await avfoundationScreenInput(ffmpeg);
|
|
360
|
+
argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
361
|
+
} else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
362
|
+
await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
|
|
363
|
+
}
|
|
364
|
+
if (requestedDestination) {
|
|
365
|
+
await copyRecordingFile(staged, requestedDestination);
|
|
366
|
+
await rm(stagingDirectory, { recursive:true, force:true });
|
|
367
|
+
}
|
|
368
|
+
const output = requestedDestination || staged;
|
|
369
|
+
const info = await stat(output);
|
|
370
|
+
keepStaging = !requestedDestination;
|
|
371
|
+
return jsonResult({ path:output, bytes:info.size, duration_seconds:seconds, format });
|
|
372
|
+
} finally {
|
|
373
|
+
if (!keepStaging) await rm(stagingDirectory, { recursive:true, force:true }).catch(() => {});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -1,35 +1,14 @@
|
|
|
1
|
-
import dns from 'node:dns';
|
|
2
|
-
import net from 'node:net';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
1
|
import process from 'node:process';
|
|
6
|
-
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { constants, existsSync } from 'node:fs';
|
|
8
|
-
import { lstat, mkdtemp, open, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
9
2
|
import { assertAllowedCommand } from '../policy.mjs';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { resolveSafePath, text, throwIfCancelled, ToolError } from '../util.mjs';
|
|
13
|
-
import {
|
|
14
|
-
clamp,
|
|
15
|
-
commandExists,
|
|
16
|
-
escapePowerShellSingle,
|
|
17
|
-
jsonResult,
|
|
18
|
-
optionalString,
|
|
19
|
-
requireEnum,
|
|
20
|
-
runFile,
|
|
21
|
-
runFileHeadLines,
|
|
22
|
-
runOsa,
|
|
23
|
-
runPowerShell,
|
|
24
|
-
safeEnvironment,
|
|
25
|
-
spawnDetached,
|
|
26
|
-
unavailable,
|
|
27
|
-
} from './common.mjs';
|
|
28
|
-
|
|
3
|
+
import { text, throwIfCancelled, ToolError } from '../util.mjs';
|
|
4
|
+
import { commandExists, requireEnum, runFile, runOsa, runPowerShell, spawnDetached, unavailable } from './common.mjs';
|
|
29
5
|
import { serviceTool, eventLog, networkTool, installedApps } from './diagnostics-platform.mjs';
|
|
30
6
|
import { environmentTool, audioTool } from './diagnostics-audio.mjs';
|
|
7
|
+
import { recordScreen } from './diagnostics-recording.mjs';
|
|
8
|
+
|
|
31
9
|
export * from './diagnostics-platform.mjs';
|
|
32
10
|
export * from './diagnostics-audio.mjs';
|
|
11
|
+
export * from './diagnostics-recording.mjs';
|
|
33
12
|
|
|
34
13
|
export const POWER_ACTION_MAX_DELAY_SECONDS = 90;
|
|
35
14
|
|
|
@@ -90,370 +69,6 @@ export async function powerAction(args, extra = {}) {
|
|
|
90
69
|
return text(`${policy.note ? `${policy.note}\n` : ''}Power action ${action} requested.`);
|
|
91
70
|
}
|
|
92
71
|
|
|
93
|
-
export function resolveRecordScreenFfmpeg({
|
|
94
|
-
platform = process.platform,
|
|
95
|
-
commandExistsFn = commandExists,
|
|
96
|
-
existsSyncFn = existsSync,
|
|
97
|
-
} = {}) {
|
|
98
|
-
if (commandExistsFn('ffmpeg')) return 'ffmpeg';
|
|
99
|
-
if (platform === 'darwin') {
|
|
100
|
-
for (const candidate of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']) {
|
|
101
|
-
if (existsSyncFn(candidate)) return candidate;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function parseAvfoundationScreenInput(output) {
|
|
108
|
-
for (const line of String(output || '').split(/\r?\n/)) {
|
|
109
|
-
const match = line.match(/\[(\d+)\]\s+Capture screen(?:\s+\d+)?\s*$/i);
|
|
110
|
-
if (match) return match[1];
|
|
111
|
-
}
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function avfoundationScreenInput(ffmpeg) {
|
|
116
|
-
const result = await runFile(ffmpeg, [
|
|
117
|
-
'-hide_banner',
|
|
118
|
-
'-f','avfoundation',
|
|
119
|
-
'-list_devices','true',
|
|
120
|
-
'-i','',
|
|
121
|
-
], { label:'AVFoundation device inventory', timeout:10_000, allowFailure:true });
|
|
122
|
-
const index = parseAvfoundationScreenInput(`${result.stderr || ''}\n${result.stdout || ''}`);
|
|
123
|
-
if (!index) unavailable('Screen recording', 'ffmpeg could not find an AVFoundation Capture screen input');
|
|
124
|
-
return index;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function gnomeScreencastCandidate({
|
|
128
|
-
platform = process.platform,
|
|
129
|
-
wayland = platform === 'linux' ? isWaylandSession() : false,
|
|
130
|
-
desktop = process.env.XDG_CURRENT_DESKTOP || '',
|
|
131
|
-
gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
|
|
132
|
-
sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
|
|
133
|
-
commandExistsFn = commandExists,
|
|
134
|
-
} = {}) {
|
|
135
|
-
const gnomeSession = /gnome/i.test(String(desktop)) || Boolean(String(gnomeSessionMode).trim());
|
|
136
|
-
return platform === 'linux'
|
|
137
|
-
&& wayland
|
|
138
|
-
&& gnomeSession
|
|
139
|
-
&& Boolean(String(sessionBus).trim())
|
|
140
|
-
&& commandExistsFn('gjs');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function recordScreenBackend({
|
|
144
|
-
platform = process.platform,
|
|
145
|
-
wayland = platform === 'linux' ? isWaylandSession() : false,
|
|
146
|
-
display = process.env.DISPLAY || '',
|
|
147
|
-
desktop = process.env.XDG_CURRENT_DESKTOP || '',
|
|
148
|
-
gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
|
|
149
|
-
sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
|
|
150
|
-
gnomeSupported = false,
|
|
151
|
-
commandExistsFn = commandExists,
|
|
152
|
-
existsSyncFn = existsSync,
|
|
153
|
-
} = {}) {
|
|
154
|
-
if (platform === 'linux') {
|
|
155
|
-
if (wayland) {
|
|
156
|
-
if (gnomeSupported && gnomeScreencastCandidate({
|
|
157
|
-
platform,
|
|
158
|
-
wayland,
|
|
159
|
-
desktop,
|
|
160
|
-
gnomeSessionMode,
|
|
161
|
-
sessionBus,
|
|
162
|
-
commandExistsFn,
|
|
163
|
-
})) return 'gnome-shell';
|
|
164
|
-
if (commandExistsFn('wf-recorder') && commandExistsFn('timeout')) return 'wf-recorder';
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
return Boolean(String(display).trim()) && commandExistsFn('ffmpeg') ? 'ffmpeg-x11' : null;
|
|
168
|
-
}
|
|
169
|
-
if (platform === 'darwin') {
|
|
170
|
-
return resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }) ? 'ffmpeg-avfoundation' : null;
|
|
171
|
-
}
|
|
172
|
-
if (platform === 'win32') {
|
|
173
|
-
return resolveRecordScreenFfmpeg({ platform, commandExistsFn, existsSyncFn }) ? 'ffmpeg-gdigrab' : null;
|
|
174
|
-
}
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function recordScreenAvailable({
|
|
179
|
-
platform = process.platform,
|
|
180
|
-
wayland = platform === 'linux' ? isWaylandSession() : false,
|
|
181
|
-
display = process.env.DISPLAY || '',
|
|
182
|
-
desktop = process.env.XDG_CURRENT_DESKTOP || '',
|
|
183
|
-
gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
|
|
184
|
-
sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
|
|
185
|
-
gnomeSupported = false,
|
|
186
|
-
commandExistsFn = commandExists,
|
|
187
|
-
existsSyncFn = existsSync,
|
|
188
|
-
} = {}) {
|
|
189
|
-
return Boolean(recordScreenBackend({
|
|
190
|
-
platform,
|
|
191
|
-
wayland,
|
|
192
|
-
display,
|
|
193
|
-
desktop,
|
|
194
|
-
gnomeSessionMode,
|
|
195
|
-
sessionBus,
|
|
196
|
-
gnomeSupported,
|
|
197
|
-
commandExistsFn,
|
|
198
|
-
existsSyncFn,
|
|
199
|
-
}));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const GNOME_SCREENCAST_SUPPORT_HELPER = `const {Gio, GLib} = imports.gi;
|
|
203
|
-
const bus = Gio.bus_get_sync(Gio.BusType.SESSION, null);
|
|
204
|
-
const value = bus.call_sync(
|
|
205
|
-
'org.gnome.Shell.Screencast',
|
|
206
|
-
'/org/gnome/Shell/Screencast',
|
|
207
|
-
'org.freedesktop.DBus.Properties',
|
|
208
|
-
'Get',
|
|
209
|
-
new GLib.Variant('(ss)', ['org.gnome.Shell.Screencast', 'ScreencastSupported']),
|
|
210
|
-
new GLib.VariantType('(v)'),
|
|
211
|
-
Gio.DBusCallFlags.NONE,
|
|
212
|
-
3000,
|
|
213
|
-
null
|
|
214
|
-
).deepUnpack();
|
|
215
|
-
print(value[0].deepUnpack() ? 'true' : 'false');
|
|
216
|
-
`;
|
|
217
|
-
|
|
218
|
-
let gnomeScreencastSupportCache = { key:'', value:false, expiresAt:0 };
|
|
219
|
-
let gnomeScreencastSupportProbe = { key:'', promise:null };
|
|
220
|
-
|
|
221
|
-
export async function gnomeScreencastSupported({
|
|
222
|
-
platform = process.platform,
|
|
223
|
-
wayland = platform === 'linux' ? isWaylandSession() : false,
|
|
224
|
-
desktop = process.env.XDG_CURRENT_DESKTOP || '',
|
|
225
|
-
gnomeSessionMode = process.env.GNOME_SHELL_SESSION_MODE || '',
|
|
226
|
-
sessionBus = process.env.DBUS_SESSION_BUS_ADDRESS || '',
|
|
227
|
-
commandExistsFn = commandExists,
|
|
228
|
-
runFileFn = runFile,
|
|
229
|
-
cacheMs = 15_000,
|
|
230
|
-
nowFn = Date.now,
|
|
231
|
-
} = {}) {
|
|
232
|
-
if (!gnomeScreencastCandidate({
|
|
233
|
-
platform,
|
|
234
|
-
wayland,
|
|
235
|
-
desktop,
|
|
236
|
-
gnomeSessionMode,
|
|
237
|
-
sessionBus,
|
|
238
|
-
commandExistsFn,
|
|
239
|
-
})) return false;
|
|
240
|
-
|
|
241
|
-
const key = [platform, wayland ? 'wayland' : '', desktop, gnomeSessionMode, sessionBus].join('|');
|
|
242
|
-
const now = nowFn();
|
|
243
|
-
if (cacheMs > 0 && gnomeScreencastSupportCache.key === key && gnomeScreencastSupportCache.expiresAt > now) {
|
|
244
|
-
return gnomeScreencastSupportCache.value;
|
|
245
|
-
}
|
|
246
|
-
if (gnomeScreencastSupportProbe.key === key && gnomeScreencastSupportProbe.promise) {
|
|
247
|
-
return gnomeScreencastSupportProbe.promise;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const promise = (async () => {
|
|
251
|
-
let value = false;
|
|
252
|
-
try {
|
|
253
|
-
const result = await runFileFn('gjs', ['-c', GNOME_SCREENCAST_SUPPORT_HELPER], {
|
|
254
|
-
label:'GNOME Shell screencast capability probe',
|
|
255
|
-
timeout:5000,
|
|
256
|
-
maxBuffer:64 * 1024,
|
|
257
|
-
allowFailure:true,
|
|
258
|
-
});
|
|
259
|
-
value = Number(result.code) === 0 && String(result.stdout || '').trim() === 'true';
|
|
260
|
-
} catch {
|
|
261
|
-
value = false;
|
|
262
|
-
}
|
|
263
|
-
gnomeScreencastSupportCache = {
|
|
264
|
-
key,
|
|
265
|
-
value,
|
|
266
|
-
expiresAt:nowFn() + Math.max(0, cacheMs),
|
|
267
|
-
};
|
|
268
|
-
return value;
|
|
269
|
-
})();
|
|
270
|
-
|
|
271
|
-
gnomeScreencastSupportProbe = { key, promise };
|
|
272
|
-
try {
|
|
273
|
-
return await promise;
|
|
274
|
-
} finally {
|
|
275
|
-
if (gnomeScreencastSupportProbe.key === key && gnomeScreencastSupportProbe.promise === promise) {
|
|
276
|
-
gnomeScreencastSupportProbe = { key:'', promise:null };
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const GNOME_SCREENCAST_HELPER = `const {Gio, GLib} = imports.gi;
|
|
282
|
-
const [destination, secondsRaw, fpsRaw] = ARGV;
|
|
283
|
-
const seconds = Number(secondsRaw);
|
|
284
|
-
const fps = Number(fpsRaw);
|
|
285
|
-
const bus = Gio.bus_get_sync(Gio.BusType.SESSION, null);
|
|
286
|
-
const destinationName = 'org.gnome.Shell.Screencast';
|
|
287
|
-
const objectPath = '/org/gnome/Shell/Screencast';
|
|
288
|
-
const interfaceName = 'org.gnome.Shell.Screencast';
|
|
289
|
-
const property = bus.call_sync(
|
|
290
|
-
destinationName,
|
|
291
|
-
objectPath,
|
|
292
|
-
'org.freedesktop.DBus.Properties',
|
|
293
|
-
'Get',
|
|
294
|
-
new GLib.Variant('(ss)', [interfaceName, 'ScreencastSupported']),
|
|
295
|
-
new GLib.VariantType('(v)'),
|
|
296
|
-
Gio.DBusCallFlags.NONE,
|
|
297
|
-
5000,
|
|
298
|
-
null
|
|
299
|
-
).deepUnpack();
|
|
300
|
-
if (!property[0].deepUnpack()) throw new Error('GNOME Shell reports ScreencastSupported=false');
|
|
301
|
-
const options = {
|
|
302
|
-
'draw-cursor': new GLib.Variant('b', true),
|
|
303
|
-
'framerate': new GLib.Variant('i', fps),
|
|
304
|
-
};
|
|
305
|
-
const started = bus.call_sync(
|
|
306
|
-
destinationName,
|
|
307
|
-
objectPath,
|
|
308
|
-
interfaceName,
|
|
309
|
-
'Screencast',
|
|
310
|
-
new GLib.Variant('(sa{sv})', [destination, options]),
|
|
311
|
-
new GLib.VariantType('(bs)'),
|
|
312
|
-
Gio.DBusCallFlags.NONE,
|
|
313
|
-
10000,
|
|
314
|
-
null
|
|
315
|
-
).deepUnpack();
|
|
316
|
-
if (!started[0]) throw new Error('GNOME Shell refused to start screencast');
|
|
317
|
-
GLib.usleep(Math.round(seconds * 1000000));
|
|
318
|
-
const stopped = bus.call_sync(
|
|
319
|
-
destinationName,
|
|
320
|
-
objectPath,
|
|
321
|
-
interfaceName,
|
|
322
|
-
'StopScreencast',
|
|
323
|
-
null,
|
|
324
|
-
new GLib.VariantType('(b)'),
|
|
325
|
-
Gio.DBusCallFlags.NONE,
|
|
326
|
-
10000,
|
|
327
|
-
null
|
|
328
|
-
).deepUnpack();
|
|
329
|
-
if (!stopped[0]) throw new Error('GNOME Shell did not stop screencast cleanly');
|
|
330
|
-
print(JSON.stringify({ destination:started[1], stopped:stopped[0] }));
|
|
331
|
-
`;
|
|
332
|
-
|
|
333
|
-
async function recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps }) {
|
|
334
|
-
const helper = path.join(stagingDirectory, 'gnome-screencast.js');
|
|
335
|
-
await writeFile(helper, GNOME_SCREENCAST_HELPER, { mode:0o600 });
|
|
336
|
-
await runFile('gjs', [helper, staged, String(seconds), String(fps)], {
|
|
337
|
-
label:'GNOME Shell screen recording',
|
|
338
|
-
timeout:(seconds + 20) * 1000,
|
|
339
|
-
maxBuffer:2 * 1024 * 1024,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
export function validateRecordingDestinationFormat(destination, format) {
|
|
344
|
-
if (!destination) return;
|
|
345
|
-
const extension = path.extname(destination).slice(1).toLowerCase();
|
|
346
|
-
if (extension && extension !== format) {
|
|
347
|
-
throw new Error(`Screen recording backend produces .${format}; destination must use .${format} or omit the extension`);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
async function openRecordingParent(destination) {
|
|
352
|
-
return openDirectoryPath(path.dirname(destination));
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function copyRecordingFile(source, destination) {
|
|
356
|
-
let input;
|
|
357
|
-
let output;
|
|
358
|
-
let parent;
|
|
359
|
-
let temporary;
|
|
360
|
-
try {
|
|
361
|
-
input = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
362
|
-
const sourceInfo = await input.stat();
|
|
363
|
-
if (!sourceInfo.isFile()) throw new Error('Screen recording staging path is not a regular file');
|
|
364
|
-
parent = await openRecordingParent(destination);
|
|
365
|
-
const anchoredDestination = path.join(parent.anchor, path.basename(destination));
|
|
366
|
-
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
367
|
-
if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
|
|
368
|
-
if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
|
|
369
|
-
temporary = path.join(parent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
|
|
370
|
-
output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
371
|
-
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
372
|
-
let offset = 0;
|
|
373
|
-
for (;;) {
|
|
374
|
-
const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
|
|
375
|
-
if (!bytesRead) break;
|
|
376
|
-
let written = 0;
|
|
377
|
-
while (written < bytesRead) {
|
|
378
|
-
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
379
|
-
if (!result.bytesWritten) throw new Error('Could not finish writing the screen recording');
|
|
380
|
-
written += result.bytesWritten;
|
|
381
|
-
}
|
|
382
|
-
offset += bytesRead;
|
|
383
|
-
}
|
|
384
|
-
await output.sync();
|
|
385
|
-
await output.close();
|
|
386
|
-
output = null;
|
|
387
|
-
await rename(temporary, anchoredDestination);
|
|
388
|
-
temporary = null;
|
|
389
|
-
} finally {
|
|
390
|
-
if (output) await output.close().catch(() => {});
|
|
391
|
-
if (temporary) await unlink(temporary).catch(() => {});
|
|
392
|
-
if (input) await input.close().catch(() => {});
|
|
393
|
-
if (parent) await parent.close().catch(() => {});
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
export async function recordScreen(args) {
|
|
398
|
-
const seconds = clamp(args.duration_seconds, 5, 1, 120);
|
|
399
|
-
const fps = clamp(args.fps, 15, 1, 60);
|
|
400
|
-
const wayland = process.platform === 'linux' && isWaylandSession();
|
|
401
|
-
const gnomeSupported = wayland ? await gnomeScreencastSupported() : false;
|
|
402
|
-
const backend = recordScreenBackend({ platform:process.platform, wayland, gnomeSupported });
|
|
403
|
-
if (!backend) {
|
|
404
|
-
if (wayland) unavailable('Screen recording', 'GNOME Shell Screencast or wf-recorder with timeout is required on Wayland');
|
|
405
|
-
if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
|
|
406
|
-
if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
|
|
407
|
-
if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
|
|
408
|
-
unavailable('Screen recording', 'ffmpeg is required on X11');
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const format = backend === 'gnome-shell' ? 'webm' : 'mp4';
|
|
412
|
-
const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
|
|
413
|
-
if (requestedDestination) {
|
|
414
|
-
validateRecordingDestinationFormat(requestedDestination, format);
|
|
415
|
-
const existing = await lstat(requestedDestination).catch(() => null);
|
|
416
|
-
if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
|
|
417
|
-
if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
|
|
421
|
-
const staged = path.join(stagingDirectory, `capture-${randomUUID()}.${format}`);
|
|
422
|
-
let keepStaging = false;
|
|
423
|
-
try {
|
|
424
|
-
if (backend === 'gnome-shell') {
|
|
425
|
-
await recordGnomeShellScreen({ staged, stagingDirectory, seconds, fps });
|
|
426
|
-
} else if (backend === 'wf-recorder') {
|
|
427
|
-
const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',staged,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
|
|
428
|
-
if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
|
|
429
|
-
} else {
|
|
430
|
-
const ffmpeg = resolveRecordScreenFfmpeg();
|
|
431
|
-
if (!ffmpeg) {
|
|
432
|
-
if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
|
|
433
|
-
if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
|
|
434
|
-
unavailable('Screen recording', 'ffmpeg is required on X11');
|
|
435
|
-
}
|
|
436
|
-
let argv;
|
|
437
|
-
if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
438
|
-
else if (process.platform === 'darwin') {
|
|
439
|
-
const input = await avfoundationScreenInput(ffmpeg);
|
|
440
|
-
argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
441
|
-
} else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
442
|
-
await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
|
|
443
|
-
}
|
|
444
|
-
if (requestedDestination) {
|
|
445
|
-
await copyRecordingFile(staged, requestedDestination);
|
|
446
|
-
await rm(stagingDirectory, { recursive:true, force:true });
|
|
447
|
-
}
|
|
448
|
-
const output = requestedDestination || staged;
|
|
449
|
-
const info = await stat(output);
|
|
450
|
-
keepStaging = !requestedDestination;
|
|
451
|
-
return jsonResult({ path:output, bytes:info.size, duration_seconds:seconds, format });
|
|
452
|
-
} finally {
|
|
453
|
-
if (!keepStaging) await rm(stagingDirectory, { recursive:true, force:true }).catch(() => {});
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
72
|
export const diagnosticHandlers = {
|
|
458
73
|
service:serviceTool,
|
|
459
74
|
event_log:eventLog,
|