@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 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
- 84 tools are implemented in this repository: the original 44 filesystem/search/terminal/process tools
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 84 granular operation names to its discovery list.
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 **84 granular runtime operations** after the selected computer's live capabilities are checked.
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 84 granular runtime operations described above; the optional widget-enabled surface is 15.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.232",
3
+ "version": "0.2.234",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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([
@@ -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
+ };