@remcp/runtime 0.2.232 → 0.2.234
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 +5 -0
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/compact-catalog.mjs +1 -0
- package/src/extended/catalog.mjs +10 -0
- package/src/extended/desktop-linux-displays.mjs +12 -2
- package/src/extended/desktop-linux-session-env.mjs +87 -0
- package/src/extended/desktop-webrtc-bridge.mjs +205 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.234 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- Publish the unchanged local runtime with the compatible 0.2.234 ReMCP agent update.
|
|
6
|
+
- The agent reconnect and memory-bound transport improvements add no new runtime tools, device permissions or protocol privileges.
|
|
7
|
+
|
|
3
8
|
## 0.2.194 — 2026-10-28
|
|
4
9
|
|
|
5
10
|
- Ship the unchanged 84-tool runtime alongside the compatible 0.2.194 ReMCP agent with terminal QR pairing.
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ node ./src/compact.mjs --print-tools # compact local façade for scanners/local
|
|
|
31
31
|
|
|
32
32
|
## Tools
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
87 tools are implemented in this repository: the original 44 filesystem/search/terminal/process tools
|
|
35
35
|
plus 40 computer-use, browser, diagnostics, and document tools. The release contract contains all 84;
|
|
36
36
|
the live local MCP `tools/list` omits optional platform-specific tools that the current computer cannot
|
|
37
37
|
support and emits `notifications/tools/list_changed` when that capability set changes. The stdio server is dual-era: existing 2025-era clients continue to work unchanged, while clients that negotiate MCP `2026-07-28` use `server/discover`, cache hints, unrestricted structured output validation, and `subscriptions/listen` for list-change delivery.
|
|
@@ -52,10 +52,10 @@ support and emits `notifications/tools/list_changed` when that capability set ch
|
|
|
52
52
|
| Diagnostics | `service`, `event_log`, `network`, `installed_apps`, `environment`, `audio`, `power_action`, `record_screen` — `audio` returns stable structured `volume`, `muted`, `action`, and `backend` fields across supported OS backends |
|
|
53
53
|
| Documents | `read_document`, `edit_spreadsheet`, `edit_document`, `pdf_action` |
|
|
54
54
|
|
|
55
|
-
The hosted ReMCP endpoint does not append those
|
|
55
|
+
The hosted ReMCP endpoint does not append those 87 granular operation names to its discovery list.
|
|
56
56
|
Current production runs with `REMCP_CUSTOM_WIDGETS_ENABLED=false` and exposes **10 hosted tool definitions**,
|
|
57
57
|
all model-visible: 8 device-facing façade tools plus 2 account/fleet tools. Those compact definitions route
|
|
58
|
-
to the same **
|
|
58
|
+
to the same **87 granular runtime operations** after the selected computer's live capabilities are checked.
|
|
59
59
|
If custom widgets are explicitly re-enabled, hosted discovery expands to **15 definitions** by adding
|
|
60
60
|
**5 app-only** UI helpers. Cached legacy clients can still call supported granular aliases without inflating
|
|
61
61
|
discovery for new clients.
|
|
@@ -90,7 +90,7 @@ errors. Linux `display_inventory` follows the real monitor backend chain (GNOME
|
|
|
90
90
|
backend (Wayland grim/GNOME/portal path or X11 ImageMagick), not merely because `ffmpeg` is installed.
|
|
91
91
|
|
|
92
92
|
The generated hosted reference covers the current **10 hosted production definitions**, all model-visible
|
|
93
|
-
and routing to the
|
|
93
|
+
and routing to the 87 granular runtime operations described above; the optional widget-enabled surface is 15.
|
|
94
94
|
The separate compact local stdio entrypoint advertises `read_file` plus up to seven domain tools
|
|
95
95
|
(up to 8 tools total) and keeps those groups capability-aware at runtime with `tools/list_changed`; the paired agent keeps using the capability-aware granular runtime.
|
|
96
96
|
See https://remcp.site/docs#tools and public `docs/TOOLS.md`.
|
package/package.json
CHANGED
package/src/compact-catalog.mjs
CHANGED
|
@@ -15,6 +15,7 @@ export const compactRuntimeGroups = Object.freeze({
|
|
|
15
15
|
'computer_snapshot','list_windows','ui_snapshot','ui_find','wait_for_ui','display_inventory',
|
|
16
16
|
'computer_action','window_action','launch_app','ui_action','type_text','keyboard','pointer','drag_drop','scroll',
|
|
17
17
|
'clipboard','open_path','reveal_path','notification','record_screen',
|
|
18
|
+
'desktop_webrtc_offer','desktop_webrtc_heartbeat','desktop_webrtc_stop',
|
|
18
19
|
]),
|
|
19
20
|
view_image: Object.freeze(['read_image','take_screenshot','screenshot_region']),
|
|
20
21
|
control_browser: Object.freeze([
|
package/src/extended/catalog.mjs
CHANGED
|
@@ -12,8 +12,10 @@ import { waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
|
|
|
12
12
|
import { commandExists, desktopSessionAvailable, runFile } from './common.mjs';
|
|
13
13
|
import { desktopHandlers } from './desktop.mjs';
|
|
14
14
|
import { displayInventoryAvailable, regionScreenshotBackends } from './desktop-linux.mjs';
|
|
15
|
+
import { hydrateLinuxDesktopEnv } from './desktop-linux-session-env.mjs';
|
|
15
16
|
import { diagnosticHandlers, gnomeScreencastSupported, recordScreenAvailable } from './diagnostics.mjs';
|
|
16
17
|
import { documentHandlers } from './documents.mjs';
|
|
18
|
+
import { desktopWebRtcHandlers, webRtcWorkerAvailable } from './desktop-webrtc-bridge.mjs';
|
|
17
19
|
|
|
18
20
|
function define(name, title, description, inputSchema, annotations, handler, requires = []) {
|
|
19
21
|
const enriched = enrichSchemaDescriptions(inputSchema);
|
|
@@ -157,6 +159,12 @@ export const extendedToolDefinitions = [
|
|
|
157
159
|
define('clipboard', 'Clipboard', 'Read, write, or clear the system text clipboard using native operating-system clipboard facilities.', o({ action:e(['read','write','clear']), text:s('Text to write.') }, ['action']), mutating, desktopHandlers.clipboard, ['clipboard']),
|
|
158
160
|
define('display_inventory', 'Display inventory', 'List connected displays/monitors and their geometry and primary-display status using native display tools.', o(), readOnlyLive, desktopHandlers.display_inventory, ['displays']),
|
|
159
161
|
define('screenshot_region', 'Screenshot region', 'Capture a desktop rectangle, a selected top-level window, or a selected monitor and return it as an inline PNG. This avoids full-desktop screenshots when only one UI area matters.', o({ x:n(), y:n(), width:n(), height:n(), window_id:s('Window id returned by list_windows.'), pid:n(), app:s(), title:s(), monitor:s('Monitor name substring, or "primary".'), monitor_index:n('Zero-based monitor index from display_inventory.'), padding:n('Optional pixels added around the resolved rectangle; 0..200.') }), readOnlyLive, desktopHandlers.screenshot_region, ['screen_region']),
|
|
162
|
+
define('desktop_webrtc_offer', 'WebRTC desktop offer (dev only)', 'Use this only after the device owner explicitly requests a live view-only session: negotiate one OS-consented video stream with the installed local aiortc worker. No embedded HTTP server, clipboard or input.',
|
|
163
|
+
o({session_id:s('Server-issued UUID.'),mode:e(['view'],'One-way view-only media. Input control uses the separate authenticated device RPC route.'),sdp:s('ICE-complete video-only SDP offer.')},['session_id','mode','sdp']),mutatingNonDestructive,desktopWebRtcHandlers.desktop_webrtc_offer,['webrtc_pilot']),
|
|
164
|
+
define('desktop_webrtc_heartbeat', 'WebRTC video heartbeat (dev only)', 'Use this only to extend the existing authorized WebRTC video session; it cannot start capture or grant input.',
|
|
165
|
+
o({session_id:s('Existing session UUID.')},['session_id']),mutatingNonDestructive,desktopWebRtcHandlers.desktop_webrtc_heartbeat,['webrtc_pilot']),
|
|
166
|
+
define('desktop_webrtc_stop', 'WebRTC video stop (dev only)', 'Use this when the user or host must end the matching WebRTC video session and release its peer, GStreamer stream and OS ScreenCast grant.',
|
|
167
|
+
o({session_id:s('Existing session UUID.')},['session_id']),mutatingNonDestructive,desktopWebRtcHandlers.desktop_webrtc_stop,['webrtc_pilot']),
|
|
160
168
|
define('open_path', 'Open path', 'Open a permitted local file or directory in its system-associated desktop application. Filesystem confinement is checked before opening.', o({ path:s('File or directory to open.') }, ['path']), additive, desktopHandlers.open_path, ['desktop_open']),
|
|
161
169
|
define('reveal_path', 'Reveal path', 'Reveal a permitted local file or directory in Finder, Explorer, or the Linux file manager after applying ReMCP filesystem confinement.', o({ path:s('File or directory to reveal.') }, ['path']), additive, desktopHandlers.reveal_path, ['desktop_open']),
|
|
162
170
|
define('notification', 'Desktop notification', 'Show a native desktop notification or user-visible popup on the paired computer.', o({ title:s(), message:s('Notification message.'), timeout_seconds:n() }, ['message']), additive, desktopHandlers.notification, ['notifications']),
|
|
@@ -250,6 +258,7 @@ export async function capabilitySnapshot() {
|
|
|
250
258
|
const win = process.platform === 'win32';
|
|
251
259
|
const mac = process.platform === 'darwin';
|
|
252
260
|
const linux = process.platform === 'linux';
|
|
261
|
+
if(linux)await hydrateLinuxDesktopEnv();
|
|
253
262
|
const desktopShellCapabilities = desktopShellCapabilitySnapshot();
|
|
254
263
|
const desktopSession = desktopShellCapabilities.desktop_session;
|
|
255
264
|
const pyAtSpi = linux && desktopSession ? await linuxHasPyAtSpi() : false;
|
|
@@ -263,6 +272,7 @@ export async function capabilitySnapshot() {
|
|
|
263
272
|
const documentCapabilities = documentCapabilitySnapshot();
|
|
264
273
|
return {
|
|
265
274
|
...desktopShellCapabilities,
|
|
275
|
+
webrtc_pilot:linux && desktopSession && webRtcWorkerAvailable(),
|
|
266
276
|
windows: win || mac || (desktopSession && (commandExists('wdotool') || commandExists('wmctrl') || (wayland && pyAtSpi))),
|
|
267
277
|
ui: win || mac || (desktopSession && pyAtSpi),
|
|
268
278
|
input: win || mac || (desktopSession && (portalInput || x11Input || (wayland && commandExists('wtype')))),
|
|
@@ -29,8 +29,8 @@ export function unwrapDbus(value) {
|
|
|
29
29
|
return value;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export async function mutterDisplayInventory() {
|
|
33
|
-
if (!isWaylandSession()) return null;
|
|
32
|
+
export async function mutterDisplayInventory({ allowUnknownSession = false } = {}) {
|
|
33
|
+
if (!allowUnknownSession && !isWaylandSession()) return null;
|
|
34
34
|
let dbus;
|
|
35
35
|
try { dbus = await import('@jellybrick/dbus-next'); } catch { return null; }
|
|
36
36
|
const bus = dbus.sessionBus();
|
|
@@ -233,6 +233,16 @@ export async function resolveLinuxDisplayInventory({
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// A systemd user agent can start before GNOME exports DISPLAY and
|
|
237
|
+
// XDG_SESSION_TYPE to its service. The authenticated user bus still
|
|
238
|
+
// exposes Mutter's real geometry; do not guess coordinates or use XTEST.
|
|
239
|
+
if (!wayland && !env.DISPLAY && !env.WAYLAND_DISPLAY
|
|
240
|
+
&& !env.XDG_SESSION_TYPE
|
|
241
|
+
&& env.DBUS_SESSION_BUS_ADDRESS && env.XDG_RUNTIME_DIR) {
|
|
242
|
+
const mutter = await mutterProbe({ allowUnknownSession:true }).catch(() => null);
|
|
243
|
+
if (Array.isArray(mutter) && mutter.length) return mutter;
|
|
244
|
+
}
|
|
245
|
+
|
|
236
246
|
if (commandExistsFn('xrandr')) {
|
|
237
247
|
const result = await runFileFn('xrandr', ['--query'], { label:'display inventory', allowFailure:true, timeout:2500, maxBuffer:4*1024*1024 });
|
|
238
248
|
if (result.code === 0) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A ReMCP user service can start before GNOME imports its graphical
|
|
2
|
+
// session into systemd --user. Restore only verified current-user Wayland
|
|
3
|
+
// environment keys; do not read arbitrary files or import secrets.
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
6
|
+
import { posix } from 'node:path';
|
|
7
|
+
import { runFile } from './common.mjs';
|
|
8
|
+
|
|
9
|
+
const SESSION_KEYS=new Set([
|
|
10
|
+
'DISPLAY','WAYLAND_DISPLAY','XDG_SESSION_TYPE','XDG_CURRENT_DESKTOP',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function sessionKeys(text) {
|
|
14
|
+
const result=Object.create(null);
|
|
15
|
+
for(const line of String(text || '').split(/\r?\n/)){
|
|
16
|
+
const idx=line.indexOf('=');
|
|
17
|
+
if(idx<1)continue;
|
|
18
|
+
const key=line.slice(0,idx);
|
|
19
|
+
if(SESSION_KEYS.has(key))result[key]=line.slice(idx+1);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function hydrateLinuxDesktopEnv({
|
|
25
|
+
platform=process.platform,
|
|
26
|
+
uid=typeof process.getuid==='function'?process.getuid():null,
|
|
27
|
+
env=process.env,
|
|
28
|
+
runFileFn=runFile,
|
|
29
|
+
lstatFn=lstat,
|
|
30
|
+
realpathFn=realpath,
|
|
31
|
+
}={}){
|
|
32
|
+
if(platform!=='linux'||env.DISPLAY||env.WAYLAND_DISPLAY||
|
|
33
|
+
(env.XDG_SESSION_TYPE && env.XDG_SESSION_TYPE!=='wayland')||
|
|
34
|
+
!env.DBUS_SESSION_BUS_ADDRESS||!env.XDG_RUNTIME_DIR||
|
|
35
|
+
!Number.isSafeInteger(uid)||!posix.isAbsolute(env.XDG_RUNTIME_DIR)){
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// Snapshot the authenticated session context before any awaited operation.
|
|
39
|
+
const runtimeDir=env.XDG_RUNTIME_DIR;
|
|
40
|
+
const busAddress=env.DBUS_SESSION_BUS_ADDRESS;
|
|
41
|
+
const sessionType=env.XDG_SESSION_TYPE;
|
|
42
|
+
let result;
|
|
43
|
+
try{
|
|
44
|
+
result=await runFileFn('systemctl',['--user','show-environment'],{
|
|
45
|
+
label:'user graphical session environment',allowFailure:true,
|
|
46
|
+
timeout:1500,maxBuffer:64*1024,
|
|
47
|
+
});
|
|
48
|
+
}catch{return false;}
|
|
49
|
+
if(result?.code!==0)return false;
|
|
50
|
+
const values=sessionKeys(result.stdout);
|
|
51
|
+
const socketName=values.WAYLAND_DISPLAY;
|
|
52
|
+
if(values.XDG_SESSION_TYPE!=='wayland'||
|
|
53
|
+
typeof socketName!=='string'||socketName.length<1||
|
|
54
|
+
socketName.length>64||socketName.includes('..')||
|
|
55
|
+
!/^[A-Za-z0-9_.-]+$/.test(socketName)){
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
try{
|
|
59
|
+
// A trailing slash makes lstat(path) follow a symlink on Linux. Resolve
|
|
60
|
+
// the *whole* path first to reject aliases via the final component or
|
|
61
|
+
// any parent directory; never import a display from an ambiguous root.
|
|
62
|
+
if(await realpathFn(runtimeDir)!==posix.resolve(runtimeDir))return false;
|
|
63
|
+
// The XDG runtime directory itself must be private (mode 0700),
|
|
64
|
+
// user-owned and not a symlink, in addition to the socket's owner.
|
|
65
|
+
const dir=await lstatFn(runtimeDir);
|
|
66
|
+
if(!dir.isDirectory()||dir.uid!==uid||(dir.mode&0o777)!==0o700)
|
|
67
|
+
return false;
|
|
68
|
+
const socket=await lstatFn(posix.join(runtimeDir,socketName));
|
|
69
|
+
if(!socket.isSocket()||socket.uid!==uid)return false;
|
|
70
|
+
}catch{return false;}
|
|
71
|
+
// A graphical login or session switch may complete during systemctl/lstat.
|
|
72
|
+
// Never overwrite its newer environment with an earlier snapshot.
|
|
73
|
+
if(env.DISPLAY||env.WAYLAND_DISPLAY||env.XDG_SESSION_TYPE!==sessionType||
|
|
74
|
+
env.XDG_RUNTIME_DIR!==runtimeDir||env.DBUS_SESSION_BUS_ADDRESS!==busAddress)
|
|
75
|
+
return false;
|
|
76
|
+
env.XDG_SESSION_TYPE='wayland';
|
|
77
|
+
env.WAYLAND_DISPLAY=socketName;
|
|
78
|
+
if(/^:[0-9]{1,4}(?:\.[0-9]{1,2})?$/.test(values.DISPLAY||'')){
|
|
79
|
+
env.DISPLAY=values.DISPLAY;
|
|
80
|
+
}
|
|
81
|
+
const desktop=values.XDG_CURRENT_DESKTOP;
|
|
82
|
+
if(typeof desktop==='string'&&desktop.length<=96&&
|
|
83
|
+
/^[A-Za-z0-9:_-]+$/.test(desktop)){
|
|
84
|
+
env.XDG_CURRENT_DESKTOP=desktop;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Explicitly opt-in Linux proof: ReMCP device RPC -> isolated aiortc worker
|
|
2
|
+
// via stdio, never a public/native HTTP listener. No OS permission bypass.
|
|
3
|
+
// Not an installed production runtime feature.
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {existsSync,lstatSync} from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import {jsonResult} from './common.mjs';
|
|
9
|
+
|
|
10
|
+
const UUID=/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
11
|
+
const MAX_SDP=60_000, MAX_LINE=70_000;
|
|
12
|
+
const MAX_IDLE_MS=45_000, MAX_AGE_MS=600_000;
|
|
13
|
+
const SOURCE=new Set(['portal','synthetic','gst-test']);
|
|
14
|
+
|
|
15
|
+
function trustedLocalFile(filename){
|
|
16
|
+
if(!filename||!path.isAbsolute(filename)||!existsSync(filename))return false;
|
|
17
|
+
try{
|
|
18
|
+
const info=lstatSync(filename);
|
|
19
|
+
return info.isFile()&&!info.isSymbolicLink()&&(info.mode&0o022)===0&&
|
|
20
|
+
(typeof process.getuid!=='function'||info.uid===process.getuid()||info.uid===0);
|
|
21
|
+
}catch{return false;}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function webRtcWorkerAvailable(env=process.env,platform=process.platform){
|
|
25
|
+
if(platform!=='linux'||env.REMCP_WEBRTC_RUNTIME_PILOT!=='1')return false;
|
|
26
|
+
if(!trustedLocalFile(env.REMCP_WEBRTC_WORKER_SCRIPT)||
|
|
27
|
+
!path.isAbsolute(env.REMCP_WEBRTC_PYTHON||''))return false;
|
|
28
|
+
const source=env.REMCP_WEBRTC_DEV_SOURCE||'portal';
|
|
29
|
+
if(!SOURCE.has(source))return false;
|
|
30
|
+
if(source!=='portal'&&env.REMCP_WEBRTC_ALLOW_TEST_SOURCE!=='1')return false;
|
|
31
|
+
return existsSync(env.REMCP_WEBRTC_PYTHON);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createDesktopWebRtcBridge({
|
|
35
|
+
env=process.env,platform=process.platform,spawnChild=spawn,
|
|
36
|
+
now=Date.now,setIntervalFn=setInterval,clearIntervalFn=clearInterval,
|
|
37
|
+
}={}){
|
|
38
|
+
let child=null, current=null, active=false, started=0, seen=0;
|
|
39
|
+
let stdout='', sequence=0, clock=null;
|
|
40
|
+
const waits=new Map();
|
|
41
|
+
|
|
42
|
+
function rejectWaits(){
|
|
43
|
+
for(const pending of waits.values()){
|
|
44
|
+
clearTimeout(pending.timer);
|
|
45
|
+
pending.reject(new Error('WebRTC worker disconnected'));
|
|
46
|
+
}
|
|
47
|
+
waits.clear();
|
|
48
|
+
}
|
|
49
|
+
function shutdown(){
|
|
50
|
+
if(clock){clearIntervalFn(clock);clock=null;}
|
|
51
|
+
const target=child;
|
|
52
|
+
child=null;current=null;active=false;stdout='';
|
|
53
|
+
rejectWaits();
|
|
54
|
+
if(target&&!target.killed){
|
|
55
|
+
try{target.stdin.end();target.kill('SIGTERM');}catch{}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function receive(chunk){
|
|
59
|
+
stdout+=chunk.toString('utf8');
|
|
60
|
+
if(Buffer.byteLength(stdout)>MAX_LINE*2)return shutdown();
|
|
61
|
+
for(let index; (index=stdout.indexOf('\n'))!==-1;){
|
|
62
|
+
const line=stdout.slice(0,index);stdout=stdout.slice(index+1);
|
|
63
|
+
if(Buffer.byteLength(line)>MAX_LINE)return shutdown();
|
|
64
|
+
let message;
|
|
65
|
+
try{message=JSON.parse(line);}catch{return shutdown();}
|
|
66
|
+
const entry=waits.get(message?.requestId);
|
|
67
|
+
if(!entry)continue;
|
|
68
|
+
waits.delete(message.requestId);
|
|
69
|
+
clearTimeout(entry.timer);
|
|
70
|
+
if(message.ok===true)entry.resolve(message);
|
|
71
|
+
else {
|
|
72
|
+
const code=['PermissionError','ValueError','WorkerFailed','Cancelled']
|
|
73
|
+
.includes(message.error)?message.error:'InvalidResponse';
|
|
74
|
+
entry.reject(new Error('WebRTC worker refused command: '+code));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function expired(){
|
|
79
|
+
return current!==null&&(now()-seen>=MAX_IDLE_MS||now()-started>=MAX_AGE_MS);
|
|
80
|
+
}
|
|
81
|
+
function send(op,sid,args={},timeout=5000){
|
|
82
|
+
if(!child||child.killed||!child.stdin.writable)
|
|
83
|
+
return Promise.reject(new Error('WebRTC worker unavailable'));
|
|
84
|
+
const requestId=++sequence;
|
|
85
|
+
const message=JSON.stringify({requestId,op,session_id:sid,...args})+'\n';
|
|
86
|
+
if(Buffer.byteLength(message)>MAX_LINE)
|
|
87
|
+
return Promise.reject(new Error('WebRTC request too large'));
|
|
88
|
+
return new Promise((resolve,reject)=>{
|
|
89
|
+
const timer=setTimeout(()=>{
|
|
90
|
+
waits.delete(requestId);
|
|
91
|
+
reject(new Error('WebRTC worker request timed out'));
|
|
92
|
+
},timeout);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
waits.set(requestId,{timer,resolve,reject});
|
|
95
|
+
try{
|
|
96
|
+
child.stdin.write(message,error=>{
|
|
97
|
+
if(!error)return;
|
|
98
|
+
if(!waits.delete(requestId))return;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
reject(new Error('WebRTC worker pipe write failed'));
|
|
101
|
+
});
|
|
102
|
+
}catch{
|
|
103
|
+
waits.delete(requestId);
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
reject(new Error('WebRTC worker pipe write failed'));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function start(){
|
|
110
|
+
if(!webRtcWorkerAvailable(env,platform))
|
|
111
|
+
throw new Error('WebRTC capture is not installed or not enabled');
|
|
112
|
+
const source=env.REMCP_WEBRTC_DEV_SOURCE||'portal';
|
|
113
|
+
// Do not leak paired-device OAuth/agent credentials into an optional
|
|
114
|
+
// native media subprocess. Only desktop-session and Python/GStreamer
|
|
115
|
+
// environment required for OS ScreenCast may cross this boundary.
|
|
116
|
+
const allowed=[
|
|
117
|
+
'HOME','PATH','LANG','LC_ALL','LC_CTYPE','TZ',
|
|
118
|
+
'XDG_RUNTIME_DIR','XDG_CURRENT_DESKTOP','XDG_SESSION_TYPE',
|
|
119
|
+
'XDG_DATA_DIRS','DBUS_SESSION_BUS_ADDRESS','WAYLAND_DISPLAY',
|
|
120
|
+
'DISPLAY','GDK_BACKEND','GI_TYPELIB_PATH','PYTHONPATH',
|
|
121
|
+
'GST_PLUGIN_PATH','GST_PLUGIN_SYSTEM_PATH_1_0',
|
|
122
|
+
'REMCP_WEBRTC_USE_PORTAL_GRANT','REMCP_WEBRTC_ALLOW_TEST_SOURCE',
|
|
123
|
+
];
|
|
124
|
+
const workerEnv=Object.fromEntries(allowed.filter(key=>typeof env[key]==='string')
|
|
125
|
+
.map(key=>[key,env[key]]));
|
|
126
|
+
// aiortc may live in a Python venv while Ubuntu supplies GObject
|
|
127
|
+
// introspection only as a distro package. Extend the worker's import
|
|
128
|
+
// path, not the agent/system environment; never install with sudo.
|
|
129
|
+
if(source==='portal'&&platform==='linux'&&existsSync('/usr/lib/python3/dist-packages')){
|
|
130
|
+
const systemPath='/usr/lib/python3/dist-packages';
|
|
131
|
+
const existing=String(workerEnv.PYTHONPATH||'').split(path.delimiter).filter(Boolean);
|
|
132
|
+
if(!existing.includes(systemPath))existing.push(systemPath);
|
|
133
|
+
workerEnv.PYTHONPATH=existing.join(path.delimiter);
|
|
134
|
+
}
|
|
135
|
+
const worker=spawnChild(env.REMCP_WEBRTC_PYTHON,
|
|
136
|
+
[env.REMCP_WEBRTC_WORKER_SCRIPT,'--source',source],{
|
|
137
|
+
stdio:['pipe','pipe','ignore'],shell:false,windowsHide:true,
|
|
138
|
+
env:workerEnv,
|
|
139
|
+
});
|
|
140
|
+
child=worker;stdout='';
|
|
141
|
+
worker.stdout.on('data',chunk=>{if(child===worker)receive(chunk);});
|
|
142
|
+
worker.once('error',()=>{if(child===worker)shutdown();});
|
|
143
|
+
worker.once('exit',()=>{if(child===worker)shutdown();});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function offer({session_id:sid,mode,sdp},extra={}){
|
|
147
|
+
if(!UUID.test(String(sid))||mode!=='view'||typeof sdp!=='string'||
|
|
148
|
+
Buffer.byteLength(sdp)>MAX_SDP||sdp.length<100)
|
|
149
|
+
throw new Error('Invalid view-only WebRTC offer');
|
|
150
|
+
if(current!==null||child)throw new Error('Another WebRTC session is active');
|
|
151
|
+
current=sid;started=seen=now();active=false;
|
|
152
|
+
let cancelled=false;
|
|
153
|
+
const cancel=()=>{cancelled=true;shutdown();};
|
|
154
|
+
extra.signal?.addEventListener('abort',cancel,{once:true});
|
|
155
|
+
try{
|
|
156
|
+
start();
|
|
157
|
+
clock=setIntervalFn(()=>{if(expired())shutdown();},1000);
|
|
158
|
+
clock.unref?.();
|
|
159
|
+
const answer=await send('offer',sid,{sdp},19_000);
|
|
160
|
+
if(cancelled||!child||current!==sid||answer.sessionId!==sid||
|
|
161
|
+
answer.type!=='answer'||typeof answer.sdp!=='string'||
|
|
162
|
+
Buffer.byteLength(answer.sdp)>MAX_SDP||expired())
|
|
163
|
+
throw new Error('Invalid WebRTC worker answer');
|
|
164
|
+
active=true;seen=now();
|
|
165
|
+
return jsonResult({sessionId:sid,type:'answer',sdp:answer.sdp});
|
|
166
|
+
}catch(error){
|
|
167
|
+
shutdown();
|
|
168
|
+
throw error;
|
|
169
|
+
}finally{
|
|
170
|
+
extra.signal?.removeEventListener('abort',cancel);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function heartbeat({session_id:sid}){
|
|
174
|
+
if(!UUID.test(String(sid))||sid!==current||!active||expired()){
|
|
175
|
+
if(expired())shutdown();
|
|
176
|
+
throw new Error('WebRTC viewer session is not active');
|
|
177
|
+
}
|
|
178
|
+
try{
|
|
179
|
+
const reply=await send('heartbeat',sid,{},4000);
|
|
180
|
+
if(reply.sessionId!==sid||!child||expired())
|
|
181
|
+
throw new Error('Invalid WebRTC lease acknowledgement');
|
|
182
|
+
seen=now();
|
|
183
|
+
return jsonResult({ok:true,sessionId:sid});
|
|
184
|
+
}catch(error){shutdown();throw error;}
|
|
185
|
+
}
|
|
186
|
+
async function stop({session_id:sid}){
|
|
187
|
+
if(!UUID.test(String(sid))||sid!==current||!child)
|
|
188
|
+
throw new Error('WebRTC viewer session not found');
|
|
189
|
+
try{
|
|
190
|
+
const reply=await send('stop',sid,{},4000);
|
|
191
|
+
if(reply.sessionId!==sid)throw new Error('Invalid WebRTC stop acknowledgement');
|
|
192
|
+
return jsonResult({ok:true,sessionId:sid});
|
|
193
|
+
}finally{shutdown();}
|
|
194
|
+
}
|
|
195
|
+
return {offer,heartbeat,stop,shutdown,
|
|
196
|
+
get activeSession(){return current;},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const bridge=createDesktopWebRtcBridge();
|
|
201
|
+
export const desktopWebRtcHandlers={
|
|
202
|
+
desktop_webrtc_offer:(args,extra)=>bridge.offer(args,extra),
|
|
203
|
+
desktop_webrtc_heartbeat:args=>bridge.heartbeat(args),
|
|
204
|
+
desktop_webrtc_stop:args=>bridge.stop(args),
|
|
205
|
+
};
|