replicas-computer 0.3.5 → 0.3.7

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/README.md CHANGED
@@ -24,7 +24,7 @@ ffmpeg captures the display and encodes 1920×1080 H.264 (and Opus from PulseAud
24
24
 
25
25
  WebRTC is the default transport, with automatic WebSocket fallback. A viewer can choose `?transport=websocket`; `REPLICAS_DESKTOP_STREAM_TRANSPORT=websocket` changes the server default for viewers that do not choose. Dashboard URLs carry the selected transport explicitly, and saved transport preferences are preserved. The viewer's WebSocket to `/stream` is the control plane either way: it carries the server's SDP offer, the viewer's answer, trickled ICE candidates, keepalives and telemetry. Pointer moves ride an unreliable data channel. When the server advertises `inputRace`, clicks, keys, wheel events and held-input release race a direct copy against ordered WebSocket delivery. The input bridge accepts only the next action ID, ignoring duplicates and out-of-order direct copies; the socket fills gaps without SCTP retransmission delays or a reorder queue. Older peers keep their existing input path. The watchdog tracks decoded frames so CSS-hidden viewers do not fall back merely because the browser stops presenting video. An explicit server-side WebRTC failure, a network that cannot pass UDP, or six seconds without decoded video switches the viewer to the socket, where the server reassembles frames and WebCodecs decodes them in the browser.
26
26
 
27
- The server makes the offer, so payload types and SSRCs are fixed and ffmpeg emits them directly. Four H.264 payload types are offered (High 4:4:4 Predictive, High, Main, Constrained Baseline); the encoder runs the best profile every connected viewer accepts, so one viewer without 4:4:4 support moves everyone to 4:2:0, and socket viewers or caps below 8 Mbps do the same. Two profiles select the encoder tuning: `balanced` (CRF 14, default chroma quantisation) and `text` (CRF 10, chroma QP offset −6); `?profile=` picks one and the most recent viewer's choice applies to the shared encoder. Quality adapts on a six-rung bitrate ladder that keeps resolution and frame rate and adds two CRF per rung under the 20 Mbps cap; `?adapt=0` pins the top rung.
27
+ The server makes the offer, so payload types and SSRCs are fixed and ffmpeg emits them directly. Four H.264 payload types are offered (High 4:4:4 Predictive, High, Main, Constrained Baseline); the encoder runs the best profile every connected viewer accepts, so one viewer without 4:4:4 support moves everyone to 4:2:0, and socket viewers or caps below 8 Mbps do the same. Two profiles select the encoder tuning: `balanced` (CRF 14, default chroma quantisation) and `text` (CRF 10, chroma QP offset −6); `?profile=` picks one and the most recent viewer's choice applies to the shared encoder. Quality adapts on a bitrate ladder down to 750 kbps that keeps resolution and frame rate and adds two CRF per rung under the default 20 Mbps cap; `?adapt=0` pins the top rung.
28
28
 
29
29
  Every video byte for a WebRTC viewer passes through `PacedSender`. libdatachannel's own pacer and NACK responder are not used because repairs bypassed the pacer and neither accounted for duplicates, so recovery traffic could exceed the link while fresh frames queued for seconds. The budget is 1.25× the rung's encoder cap and covers media, repairs and protective copies together: repairs take at most 30% of it over a rolling second, copies are sent only from idle budget while loss is observed and never exceed the original media bytes, and a queue that grows past 150 ms is cut back to whole frames a decoder can still follow (unsent delta frames go, the in-flight frame completes, a keyframe is requested and further deltas are dropped until it arrives). Sequence numbers are assigned as packets leave, so dropped frames never read as loss. Receiver PLI, NACK and REMB are parsed from RTCP. RTT growth (the lowest probe of the last second against the session's baseline, so a single late probe is not a queue), loss, jitter-buffer growth and stale-queue cuts drive the ladder down, and clean seconds drive it up. REMB is not a congestion vote: the receiver's estimate is bounded by what recently arrived and climbs by tens of kbps per second, so it trails every burst of motion on a quiet screen for a minute or more, and its delay detector reads the sender's pacing bursts on a quiet screen as overuse. It only bounds how far a severe descent (heavy loss or a starved decoder) steps, and only while it has stopped climbing below the sent rate. `scripts/probe-viewer.ts` drives the viewer page in a Chrome with remote debugging and prints what it measures and what the server decides each second; `scripts/probe-rtt-jitter.ts` feeds the controller a synthetic WebRTC viewer with jittery RTT probes.
30
30
 
@@ -39,7 +39,7 @@ The server records every decision that changes the picture (viewer connects and
39
39
  | `REPLICAS_DESKTOP_STREAM_TRANSPORT` | `webrtc` | Transport for viewers whose URL does not choose one |
40
40
  | `REPLICAS_DESKTOP_STREAM_PROFILE` | `balanced` | Encoder profile for viewers whose URL does not choose one |
41
41
  | `REPLICAS_DESKTOP_STREAM_ADAPTIVE` | `1` | `0` pins the top quality rung by default |
42
- | `REPLICAS_DESKTOP_STREAM_ICE_SERVERS` | `stun:stun.l.google.com:19302` | Comma-separated STUN/TURN servers; `turn:user:secret@host:port?transport=tcp` adds a relay. Re-read from the workspace runtime env on every viewer, so the monolith's `DESKTOP_STREAM_ICE_SERVERS` reaches running workspaces |
42
+ | `REPLICAS_DESKTOP_STREAM_ICE_SERVERS` | `stun:stun.l.google.com:19302` | Comma-separated STUN/TURN servers; `turn:user:secret@host:port?transport=tcp` adds a relay. Percent-encode the username and password; the browser receives their decoded values. Re-read from the workspace runtime env on every viewer, so the monolith's `DESKTOP_STREAM_ICE_SERVERS` reaches running workspaces |
43
43
  | `REPLICAS_DESKTOP_STREAM_ICE_PORTS` | ephemeral | `begin-end` UDP port range for the server's ICE candidates |
44
44
  | `REPLICAS_DESKTOP_STREAM_WEBRTC` | `1` | `0` serves the WebSocket transport only |
45
45
  | `REPLICAS_DESKTOP_STREAM_COLOR` | `bt709` | `legacy` disables the explicit BT.709 conversion and signalling |
@@ -55,4 +55,4 @@ replicas-computer daemon
55
55
 
56
56
  `setup --check` reports missing OS dependencies, including the native WebRTC module, without changing the machine. `setup` installs package-owned assets and the `replicas-computer.service` systemd unit. Operational commands use the daemon over a user-only Unix socket.
57
57
 
58
- The sandbox image builds node-datachannel from pinned libdatachannel source with `scripts/build-webrtc.sh`, which applies `patches/libdatachannel-dtls-bio.patch` (upstream DTLS BIO synchronisation fix, [2059d2a](https://github.com/paullouisageneau/libdatachannel/commit/2059d2a3bb81dc3dc24686fdc92c90b6167ec4fd)) and `patches/libdatachannel-nack-duplicates.patch`. It needs CMake, Ninja, a C++ compiler, Git and OpenSSL development libraries. Remove each patch when the pinned addon includes its fix.
58
+ The sandbox image builds node-datachannel from pinned libdatachannel source with `scripts/build-webrtc.sh`, which applies `patches/libdatachannel-dtls-bio.patch` (upstream DTLS BIO synchronisation fix, [2059d2a](https://github.com/paullouisageneau/libdatachannel/commit/2059d2a3bb81dc3dc24686fdc92c90b6167ec4fd)) and `patches/libdatachannel-nack-duplicates.patch`. It also applies `patches/libjuice-ice-role-presence.patch` so valid zero-valued ICE role attributes do not break Chrome TURN/TCP connectivity. It needs CMake, Ninja, a C++ compiler, Git and OpenSSL development libraries. Remove each patch when the pinned addon includes its fix.
package/dist/index.js CHANGED
@@ -2937,7 +2937,7 @@ async function computerStatusCommand() {
2937
2937
  }
2938
2938
 
2939
2939
  // src/version.ts
2940
- var COMPUTER_USE_VERSION = "0.3.5";
2940
+ var COMPUTER_USE_VERSION = "0.3.7";
2941
2941
  var COMPUTER_USE_PROTOCOL_VERSION = 1;
2942
2942
 
2943
2943
  // src/program.ts
@@ -3755,6 +3755,62 @@ var InputBridge = class {
3755
3755
  }
3756
3756
  };
3757
3757
 
3758
+ // ../shared/src/desktop-stream.ts
3759
+ var DEFAULT_STREAM_REQUEST = {
3760
+ transport: "webrtc",
3761
+ profile: "balanced",
3762
+ adaptive: true,
3763
+ iceTransportPolicy: "all"
3764
+ };
3765
+ function parseStreamRequest(query, defaults = DEFAULT_STREAM_REQUEST) {
3766
+ const transport = query.get("transport");
3767
+ const profile = query.get("profile");
3768
+ const adapt = query.get("adapt");
3769
+ const ice = query.get("ice");
3770
+ return {
3771
+ transport: transport === "webrtc" || transport === "websocket" ? transport : defaults.transport,
3772
+ profile: profile === "text" || profile === "balanced" ? profile : defaults.profile,
3773
+ adaptive: adapt === "0" ? false : adapt === "1" ? true : defaults.adaptive,
3774
+ iceTransportPolicy: ice === "relay" || ice === "all" ? ice : defaults.iceTransportPolicy
3775
+ };
3776
+ }
3777
+ var H264_PROFILES = {
3778
+ high444: { payloadType: 99, profileLevelId: "f4001f", codec: "avc1.F4002A", label: "High 4:4:4" },
3779
+ high: { payloadType: 96, profileLevelId: "64001f", codec: "avc1.64002A", label: "High" },
3780
+ main: { payloadType: 97, profileLevelId: "4d001f", codec: "avc1.4D002A", label: "Main" },
3781
+ baseline: { payloadType: 98, profileLevelId: "42e01f", codec: "avc1.42E02A", label: "Baseline" }
3782
+ };
3783
+ var PROFILE_ORDER = ["high444", "high", "main", "baseline"];
3784
+ var DESKTOP_WEBRTC_FAILURE_REASONS = {
3785
+ server_unavailable: "server has no WebRTC",
3786
+ browser_unavailable: "browser has no WebRTC",
3787
+ connection_failed: "WebRTC connection failed",
3788
+ server_connection_failed: "Server WebRTC connection failed",
3789
+ codec_unsupported: "browser cannot receive H.264 over WebRTC",
3790
+ negotiation_failed: "negotiation failed",
3791
+ video_timeout: "WebRTC did not decode video in time",
3792
+ video_stalled: "WebRTC video stopped arriving"
3793
+ };
3794
+ function isDesktopWebRtcFailure(value) {
3795
+ if (!isRecord(value) || typeof value.reasonCode !== "string" || !Object.hasOwn(DESKTOP_WEBRTC_FAILURE_REASONS, value.reasonCode) || typeof value.turnConfigured !== "boolean") return false;
3796
+ return [
3797
+ "id",
3798
+ "connectionState",
3799
+ "iceConnectionState",
3800
+ "iceGatheringState",
3801
+ "signalingState",
3802
+ "iceTransportPolicy",
3803
+ "serverVersion",
3804
+ "serverUnavailableReason",
3805
+ "localCandidateTypes",
3806
+ "remoteCandidateTypes",
3807
+ "iceErrorCodes",
3808
+ "errorName",
3809
+ "path",
3810
+ "codec"
3811
+ ].every((key) => typeof value[key] === "string" && value[key].length <= 200) && ["elapsedMs", "candidateErrors", "framesDecoded", "packetsReceived", "packetsLost", "bytesReceived", "lastFrameAgeMs", "encoderFps"].every((key) => typeof value[key] === "number" && Number.isFinite(value[key]));
3812
+ }
3813
+
3758
3814
  // src/input-validation.ts
3759
3815
  var finite = (value) => typeof value === "number" && Number.isFinite(value);
3760
3816
  var optionalFinite = (value) => value === void 0 || finite(value);
@@ -3780,7 +3836,7 @@ function validInput(value) {
3780
3836
  case "candidate":
3781
3837
  return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
3782
3838
  case "fallback":
3783
- return typeof value.reason === "string" && value.reason.length < 200;
3839
+ return typeof value.reason === "string" && value.reason.length < 200 && (value.failure === void 0 || isDesktopWebRtcFailure(value.failure));
3784
3840
  case "pointer":
3785
3841
  return finite(value.x) && finite(value.y);
3786
3842
  case "button":
@@ -3801,33 +3857,6 @@ function parseInput(raw) {
3801
3857
  return validInput(value) ? value : void 0;
3802
3858
  }
3803
3859
 
3804
- // ../shared/src/desktop-stream.ts
3805
- var DEFAULT_STREAM_REQUEST = {
3806
- transport: "webrtc",
3807
- profile: "balanced",
3808
- adaptive: true,
3809
- iceTransportPolicy: "all"
3810
- };
3811
- function parseStreamRequest(query, defaults = DEFAULT_STREAM_REQUEST) {
3812
- const transport = query.get("transport");
3813
- const profile = query.get("profile");
3814
- const adapt = query.get("adapt");
3815
- const ice = query.get("ice");
3816
- return {
3817
- transport: transport === "webrtc" || transport === "websocket" ? transport : defaults.transport,
3818
- profile: profile === "text" || profile === "balanced" ? profile : defaults.profile,
3819
- adaptive: adapt === "0" ? false : adapt === "1" ? true : defaults.adaptive,
3820
- iceTransportPolicy: ice === "relay" || ice === "all" ? ice : defaults.iceTransportPolicy
3821
- };
3822
- }
3823
- var H264_PROFILES = {
3824
- high444: { payloadType: 99, profileLevelId: "f4001f", codec: "avc1.F4002A", label: "High 4:4:4" },
3825
- high: { payloadType: 96, profileLevelId: "64001f", codec: "avc1.64002A", label: "High" },
3826
- main: { payloadType: 97, profileLevelId: "4d001f", codec: "avc1.4D002A", label: "Main" },
3827
- baseline: { payloadType: 98, profileLevelId: "42e01f", codec: "avc1.42E02A", label: "Baseline" }
3828
- };
3829
- var PROFILE_ORDER = ["high444", "high", "main", "baseline"];
3830
-
3831
3860
  // src/media-protocol.ts
3832
3861
  var STREAM_PROTOCOL_VERSION = 3;
3833
3862
  var MEDIA_PACKET_HEADER_BYTES = 12;
@@ -4097,7 +4126,15 @@ function parseIceServers(spec) {
4097
4126
  const native = (spec ?? "stun:stun.l.google.com:19302").split(",").map((server) => server.trim()).filter(Boolean);
4098
4127
  const browser = native.map((url) => {
4099
4128
  const match = url.match(/^(turns?):([^:@]+):(.+)@([^@]+)$/);
4100
- return match ? { urls: `${match[1]}:${match[4]}`, username: match[2], credential: match[3] } : { urls: url };
4129
+ if (!match) return { urls: url };
4130
+ const [username, credential] = match.slice(2, 4).map((value) => {
4131
+ try {
4132
+ return decodeURIComponent(value);
4133
+ } catch {
4134
+ return value;
4135
+ }
4136
+ });
4137
+ return { urls: `${match[1]}:${match[4]}`, username, credential };
4101
4138
  });
4102
4139
  return { native, browser };
4103
4140
  }
@@ -4255,7 +4292,7 @@ var profiles = {
4255
4292
  balanced: { crf: 14, chromaQpOffset: 0 },
4256
4293
  text: { crf: 10, chromaQpOffset: -6 }
4257
4294
  };
4258
- var rungCaps = [configuredTopKbps, 12e3, 8e3, 5e3, 3e3, 1500].map((kbps) => Math.min(kbps, configuredTopKbps));
4295
+ var rungCaps = [configuredTopKbps, 12e3, 8e3, 5e3, 3e3, 1500, 750].map((kbps) => Math.min(kbps, configuredTopKbps));
4259
4296
  var qualityLadder = (profile) => rungCaps.map((kbps, index) => ({ kbps, crf: profiles[profile].crf + 2 * index, keyframeMs: index >= 4 ? 2e3 : 1e3 }));
4260
4297
  var fullChromaMinKbps = 8e3;
4261
4298
  var fallbackKeyframeMs = 250;
@@ -4371,6 +4408,7 @@ async function runMediaServer() {
4371
4408
  let videoBytes = 0;
4372
4409
  let audioBytes = 0;
4373
4410
  const events = [];
4411
+ const webrtcFailures = [];
4374
4412
  const samples = [];
4375
4413
  const record = (event, detail) => {
4376
4414
  if (events.push({ at: Date.now(), event, detail }) > eventHistory) events.shift();
@@ -4497,6 +4535,7 @@ async function runMediaServer() {
4497
4535
  colorMode === "bt709" ? `scale=out_color_matrix=bt709:out_range=tv,format=${pixelFormat}` : `format=${pixelFormat}`
4498
4536
  ];
4499
4537
  const x264 = [
4538
+ `fps=${fps}/1`,
4500
4539
  "repeat-headers=1",
4501
4540
  "sliced-threads=1",
4502
4541
  "slices=4",
@@ -4534,6 +4573,8 @@ async function runMediaServer() {
4534
4573
  "1",
4535
4574
  "-i",
4536
4575
  `${display}.0`,
4576
+ "-filter_threads",
4577
+ "1",
4537
4578
  "-vf",
4538
4579
  filters.join(","),
4539
4580
  ...warmupFrames ? ["-force_key_frames", `expr:eq(n,${warmupFrames})`, "-forced-idr", "1"] : [],
@@ -4904,7 +4945,14 @@ async function runMediaServer() {
4904
4945
  record("keyframe", reason);
4905
4946
  void videoEncoder.replace();
4906
4947
  };
4907
- const enterFallback = (viewer, reason) => {
4948
+ const enterFallback = (viewer, reason, failure) => {
4949
+ if (failure && !viewer.failureReported && viewer.request.transport === "webrtc") {
4950
+ viewer.failureReported = true;
4951
+ const entry = { ...failure, at: Date.now() };
4952
+ if (webrtcFailures.push(entry) > 50) webrtcFailures.shift();
4953
+ process.stderr.write(`[desktop-stream] webrtc fallback diagnostics: ${JSON.stringify(entry)}
4954
+ `);
4955
+ }
4908
4956
  if (viewer.fallback) return;
4909
4957
  viewer.fallback = true;
4910
4958
  viewer.waitingForKeyframe = true;
@@ -5001,7 +5049,7 @@ async function runMediaServer() {
5001
5049
  viewer.rtc?.candidate(message.candidate, message.mid);
5002
5050
  return;
5003
5051
  case "fallback":
5004
- enterFallback(viewer, message.reason);
5052
+ enterFallback(viewer, message.reason, message.failure);
5005
5053
  return;
5006
5054
  case "keyframe":
5007
5055
  if (!viewer.fallback) requestKeyframe("viewer watchdog", void 0, viewer);
@@ -5016,6 +5064,8 @@ async function runMediaServer() {
5016
5064
  const config = {
5017
5065
  type: "config",
5018
5066
  protocolVersion: STREAM_PROTOCOL_VERSION,
5067
+ serverVersion: COMPUTER_USE_VERSION,
5068
+ ...!webrtc ? { webrtcUnavailableReason: webrtcEnabled ? "native_module_unavailable" : "disabled" } : {},
5019
5069
  inputRace: true,
5020
5070
  width,
5021
5071
  height,
@@ -5070,7 +5120,7 @@ async function runMediaServer() {
5070
5120
  width,
5071
5121
  height,
5072
5122
  fps,
5073
- ...url.pathname === "/telemetry" ? { events, samples } : {}
5123
+ ...url.pathname === "/telemetry" ? { events, samples, webrtcFailures } : {}
5074
5124
  }));
5075
5125
  return;
5076
5126
  }
@@ -5168,9 +5218,10 @@ async function runMediaServer() {
5168
5218
  });
5169
5219
  } catch (error) {
5170
5220
  record("webrtc error", `could not create a peer connection: ${error instanceof Error ? error.message : String(error)}`);
5221
+ reply({ type: "webrtc-failed" });
5171
5222
  }
5172
5223
  }
5173
- if (!viewer.rtc) enterFallback(viewer, offerWebRtc ? "webrtc unavailable" : "websocket requested");
5224
+ if (!viewer.rtc) enterFallback(viewer, request.transport === "webrtc" ? "webrtc unavailable" : "websocket requested");
5174
5225
  ensureEncoders();
5175
5226
  socket.on("message", (raw, binary) => {
5176
5227
  if (binary) return;
@@ -3,39 +3,6 @@ function isRecord(value) {
3
3
  return typeof value === "object" && value !== null && !Array.isArray(value);
4
4
  }
5
5
 
6
- // src/input-validation.ts
7
- var finite = (value) => typeof value === "number" && Number.isFinite(value);
8
- var iceServer = (value) => {
9
- if (!isRecord(value)) return false;
10
- return typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
11
- };
12
- function validServerMessage(value) {
13
- if (!isRecord(value) || typeof value.type !== "string") return false;
14
- switch (value.type) {
15
- case "ping":
16
- case "pong":
17
- return finite(value.sentAt);
18
- case "webrtc-failed":
19
- return true;
20
- case "offer":
21
- return typeof value.sdp === "string" && value.sdp.length < 65536;
22
- case "candidate":
23
- return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
24
- case "config":
25
- return finite(value.protocolVersion) && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(iceServer));
26
- case "telemetry":
27
- return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer) && Array.isArray(value.events);
28
- default:
29
- return false;
30
- }
31
- }
32
-
33
- // src/wheel.ts
34
- function wheelDeltaPixels(event, pageHeight) {
35
- const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 32 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? pageHeight : 1;
36
- return { deltaX: event.deltaX * scale, deltaY: event.deltaY * scale };
37
- }
38
-
39
6
  // ../shared/src/desktop-stream.ts
40
7
  var DEFAULT_STREAM_REQUEST = {
41
8
  transport: "webrtc",
@@ -67,6 +34,49 @@ function h264ProfileLabel(sdpFmtpLine) {
67
34
  return match?.label ?? id ?? "";
68
35
  }
69
36
  var DESKTOP_TRANSPORT_MESSAGE = "replicas-desktop-transport";
37
+ var DESKTOP_WEBRTC_FAILURE_REASONS = {
38
+ server_unavailable: "server has no WebRTC",
39
+ browser_unavailable: "browser has no WebRTC",
40
+ connection_failed: "WebRTC connection failed",
41
+ server_connection_failed: "Server WebRTC connection failed",
42
+ codec_unsupported: "browser cannot receive H.264 over WebRTC",
43
+ negotiation_failed: "negotiation failed",
44
+ video_timeout: "WebRTC did not decode video in time",
45
+ video_stalled: "WebRTC video stopped arriving"
46
+ };
47
+
48
+ // src/input-validation.ts
49
+ var finite = (value) => typeof value === "number" && Number.isFinite(value);
50
+ var iceServer = (value) => {
51
+ if (!isRecord(value)) return false;
52
+ return typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
53
+ };
54
+ function validServerMessage(value) {
55
+ if (!isRecord(value) || typeof value.type !== "string") return false;
56
+ switch (value.type) {
57
+ case "ping":
58
+ case "pong":
59
+ return finite(value.sentAt);
60
+ case "webrtc-failed":
61
+ return true;
62
+ case "offer":
63
+ return typeof value.sdp === "string" && value.sdp.length < 65536;
64
+ case "candidate":
65
+ return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
66
+ case "config":
67
+ return finite(value.protocolVersion) && (value.serverVersion === void 0 || typeof value.serverVersion === "string") && (value.webrtcUnavailableReason === void 0 || typeof value.webrtcUnavailableReason === "string") && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(iceServer));
68
+ case "telemetry":
69
+ return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer) && Array.isArray(value.events);
70
+ default:
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // src/wheel.ts
76
+ function wheelDeltaPixels(event, pageHeight) {
77
+ const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 32 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? pageHeight : 1;
78
+ return { deltaX: event.deltaX * scale, deltaY: event.deltaY * scale };
79
+ }
70
80
 
71
81
  // src/media-protocol.ts
72
82
  var STREAM_PROTOCOL_VERSION = 3;
@@ -138,6 +148,13 @@ globalThis.__replicasDesktop = state;
138
148
  var viewerEvents = [];
139
149
  state.events = viewerEvents;
140
150
  var decoderResets = 0;
151
+ var connectionStartedAt = performance.now();
152
+ var failure;
153
+ var localCandidateTypes = /* @__PURE__ */ new Set();
154
+ var remoteCandidateTypes = /* @__PURE__ */ new Set();
155
+ var iceErrorCodes = /* @__PURE__ */ new Set();
156
+ var candidateErrors = 0;
157
+ var negotiationErrorName = "";
141
158
  var note = (event, detail) => {
142
159
  if (viewerEvents.push({ at: Date.now(), event, detail }) > 50) viewerEvents.shift();
143
160
  };
@@ -208,7 +225,7 @@ var sendInput = (message, unreliable = false) => {
208
225
  };
209
226
  var announceTransport = () => {
210
227
  if (window.parent === window) return;
211
- window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec }, "*");
228
+ window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure }, "*");
212
229
  };
213
230
  var closePeer = (preserveDeadline = false) => {
214
231
  if (!preserveDeadline) {
@@ -345,7 +362,7 @@ var handleBinary = (data) => {
345
362
  }
346
363
  if (!videoDecoder || !activeVideoConfig || videoDecoder.state !== "configured") return;
347
364
  const key = kind === 1 /* VideoKey */;
348
- if (videoDecoder.decodeQueueSize > 3) {
365
+ if (videoDecoder.decodeQueueSize > 12) {
349
366
  videoDecoder.reset();
350
367
  videoDecoder.configure(activeVideoConfig);
351
368
  waitingForKeyframe = true;
@@ -379,8 +396,38 @@ var unlockAudio = () => {
379
396
  });
380
397
  });
381
398
  };
382
- var useFallback = (reason, failed = false) => {
399
+ var useFallback = (reasonCode, failed = false) => {
383
400
  if (transport === "websocket" || !config) return;
401
+ const reason = reasonCode === "websocket_selected" ? "WebSocket transport selected" : reasonCode === "recent_failure" ? "recent WebRTC failure" : DESKTOP_WEBRTC_FAILURE_REASONS[reasonCode];
402
+ if (request.transport === "webrtc" && reasonCode !== "websocket_selected" && reasonCode !== "recent_failure") {
403
+ failure = {
404
+ id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
405
+ reasonCode,
406
+ elapsedMs: Math.round(performance.now() - connectionStartedAt),
407
+ connectionState: peer?.connectionState ?? "unavailable",
408
+ iceConnectionState: peer?.iceConnectionState ?? "unavailable",
409
+ iceGatheringState: peer?.iceGatheringState ?? "unavailable",
410
+ signalingState: peer?.signalingState ?? "unavailable",
411
+ iceTransportPolicy: request.iceTransportPolicy,
412
+ turnConfigured: config.iceServers?.some((server) => /^turns?:/i.test(server.urls)) ?? false,
413
+ serverVersion: config.serverVersion ?? "unknown",
414
+ serverUnavailableReason: config.webrtcUnavailableReason ?? "",
415
+ localCandidateTypes: [...localCandidateTypes].join(","),
416
+ remoteCandidateTypes: [...remoteCandidateTypes].join(","),
417
+ iceErrorCodes: [...iceErrorCodes].join(","),
418
+ candidateErrors,
419
+ errorName: negotiationErrorName,
420
+ path: route.path,
421
+ codec: route.codec,
422
+ framesDecoded: inbound?.framesDecoded ?? 0,
423
+ packetsReceived: inbound?.packetsReceived ?? 0,
424
+ packetsLost: inbound?.packetsLost ?? 0,
425
+ bytesReceived: inbound?.bytesReceived ?? 0,
426
+ lastFrameAgeMs: lastFrameAt ? Math.round(performance.now() - lastFrameAt) : -1,
427
+ encoderFps: telemetry?.encoder.fps ?? -1
428
+ };
429
+ state.lastWebRtcFailure = failure;
430
+ }
384
431
  closePeer();
385
432
  if (failed) webrtcSkips = webrtcSkipsAfterFailure;
386
433
  note("fallback to socket", reason);
@@ -388,11 +435,11 @@ var useFallback = (reason, failed = false) => {
388
435
  transportReason = reason;
389
436
  route = { ...route, path: "proxy", codec: config.videoCodec };
390
437
  announceTransport();
438
+ sendSocket({ type: "fallback", reason, failure });
391
439
  if (!TrackGenerator) {
392
440
  showStatus("This browser cannot render the desktop stream");
393
441
  return;
394
442
  }
395
- sendSocket({ type: "fallback", reason });
396
443
  resetVideoTrack();
397
444
  void configureVideo(config).catch((error) => showStatus(describe(error)));
398
445
  void screen.play().catch(() => showStatus("Click the stream to begin playback"));
@@ -427,8 +474,14 @@ var negotiate = async (sdp) => {
427
474
  };
428
475
  pc.onicecandidate = (event) => {
429
476
  if (peer !== pc) return;
477
+ if (event.candidate?.type) localCandidateTypes.add(event.candidate.type);
430
478
  if (event.candidate?.candidate) sendSocket({ type: "candidate", candidate: event.candidate.candidate, mid: event.candidate.sdpMid ?? "0" });
431
479
  };
480
+ pc.onicecandidateerror = (event) => {
481
+ if (peer !== pc) return;
482
+ iceErrorCodes.add(event.errorCode);
483
+ note("ice candidate error", String(event.errorCode));
484
+ };
432
485
  pc.oniceconnectionstatechange = () => {
433
486
  if (peer === pc) note("ice", pc.iceConnectionState);
434
487
  };
@@ -444,7 +497,7 @@ var negotiate = async (sdp) => {
444
497
  unlockAudio();
445
498
  } else if (pc.connectionState === "failed") {
446
499
  if (transport === "webrtc") socket?.close();
447
- else useFallback("WebRTC connection failed", true);
500
+ else useFallback("connection_failed", true);
448
501
  }
449
502
  };
450
503
  try {
@@ -456,7 +509,7 @@ var negotiate = async (sdp) => {
456
509
  if (peer !== pc || !answer.sdp) return;
457
510
  const video = answer.sdp.split(/^m=/m).find((section) => section.startsWith("video")) ?? "";
458
511
  if (video.startsWith("video 0 ") || !/^a=rtpmap:\d+ H264\/90000/m.test(video)) {
459
- useFallback("browser cannot receive H.264 over WebRTC", true);
512
+ useFallback("codec_unsupported", true);
460
513
  return;
461
514
  }
462
515
  sendSocket({ type: "answer", sdp: answer.sdp });
@@ -484,15 +537,22 @@ var handleServerMessage = (message, viaSocket) => {
484
537
  return;
485
538
  }
486
539
  if (message.type === "webrtc-failed") {
487
- useFallback("Server WebRTC connection failed", true);
540
+ useFallback("server_connection_failed", true);
488
541
  return;
489
542
  }
490
543
  if (message.type === "offer") {
491
- void negotiate(message.sdp).catch((error) => useFallback(`negotiation failed: ${describe(error)}`, true));
544
+ void negotiate(message.sdp).catch((error) => {
545
+ negotiationErrorName = error instanceof Error ? error.name : "unknown";
546
+ useFallback("negotiation_failed", true);
547
+ });
492
548
  return;
493
549
  }
494
550
  if (message.type === "candidate") {
495
- void peer?.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid }).catch(() => {
551
+ const candidateType = message.candidate.match(/\btyp (host|srflx|prflx|relay)\b/)?.[1];
552
+ if (candidateType) remoteCandidateTypes.add(candidateType);
553
+ const pc = peer;
554
+ void pc?.addIceCandidate({ candidate: message.candidate, sdpMid: message.mid }).catch(() => {
555
+ if (peer === pc) candidateErrors += 1;
496
556
  });
497
557
  return;
498
558
  }
@@ -504,15 +564,15 @@ var handleServerMessage = (message, viaSocket) => {
504
564
  config = message;
505
565
  request = message.request;
506
566
  fitScreen();
507
- if (request.transport !== "webrtc") useFallback("WebSocket transport selected");
508
- else if (!message.iceServers) useFallback("server has no WebRTC");
509
- else if (typeof RTCPeerConnection !== "function") useFallback("browser has no WebRTC");
567
+ if (request.transport !== "webrtc") useFallback("websocket_selected");
568
+ else if (!message.iceServers) useFallback("server_unavailable");
569
+ else if (typeof RTCPeerConnection !== "function") useFallback("browser_unavailable");
510
570
  else if (webrtcSkips > 0) {
511
571
  webrtcSkips -= 1;
512
- useFallback("recent WebRTC failure");
572
+ useFallback("recent_failure");
513
573
  } else if (!transport) {
514
574
  showStatus("Establishing connection\u2026");
515
- webrtcDeadline ??= setTimeout(() => useFallback("WebRTC did not decode video in time", true), webrtcFrameTimeoutMs);
575
+ webrtcDeadline ??= setTimeout(() => useFallback("video_timeout", true), webrtcFrameTimeoutMs);
516
576
  }
517
577
  };
518
578
  var handleServerText = (data, viaSocket) => {
@@ -529,6 +589,19 @@ var connect = () => {
529
589
  clearTimeout(reconnectTimer);
530
590
  closePeer();
531
591
  transport = void 0;
592
+ transportReason = "";
593
+ failure = void 0;
594
+ config = void 0;
595
+ telemetry = void 0;
596
+ connectionStartedAt = performance.now();
597
+ localCandidateTypes.clear();
598
+ remoteCandidateTypes.clear();
599
+ iceErrorCodes.clear();
600
+ candidateErrors = 0;
601
+ negotiationErrorName = "";
602
+ lastFrameAt = 0;
603
+ route = { path: "", codec: "", decoded: "", kbps: 0, displayedFps: 0 };
604
+ announceTransport();
532
605
  socket?.close();
533
606
  showStatus(reconnectAttempt ? "reconnecting\u2026" : "connecting\u2026");
534
607
  const query = new URLSearchParams([...params].filter(([key]) => ["transport", "profile", "adapt", "ice"].includes(key)));
@@ -754,7 +827,7 @@ setInterval(() => {
754
827
  if (screen.paused && screen.srcObject instanceof MediaStream && screen.srcObject.getVideoTracks().some((track) => track.readyState === "live")) return;
755
828
  const now = performance.now();
756
829
  if (now - lastFrameAt >= webrtcFrameTimeoutMs) {
757
- useFallback("WebRTC video stopped arriving", true);
830
+ useFallback("video_stalled", true);
758
831
  return;
759
832
  }
760
833
  if (inputChannel?.readyState !== "open") return;
@@ -778,7 +851,7 @@ setInterval(() => {
778
851
  displayedWindow = { at: now, frames: presentedFrames };
779
852
  }
780
853
  const elapsedSeconds = (now - renderedWindowStartedAt) / 1e3;
781
- if (transport !== "webrtc" && elapsedSeconds < 1) return;
854
+ if (!peer && transport !== "webrtc" && elapsedSeconds < 1) return;
782
855
  const stats = {
783
856
  sequence: ++statsSequence,
784
857
  videoFps: renderedFrames / elapsedSeconds,
@@ -789,7 +862,7 @@ setInterval(() => {
789
862
  renderedFrames = 0;
790
863
  decodedAudioPackets = 0;
791
864
  renderedWindowStartedAt = now;
792
- if (transport === "webrtc" && peer) void reportWebRtcStats(peer, stats).catch(() => {
865
+ if (peer) void reportWebRtcStats(peer, stats).catch(() => {
793
866
  });
794
867
  else if (transport === "websocket") {
795
868
  sendInput({ type: "stats", ...stats });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-computer",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -12,7 +12,8 @@
12
12
  "assets",
13
13
  "scripts/build-webrtc.sh",
14
14
  "patches/libdatachannel-dtls-bio.patch",
15
- "patches/libdatachannel-nack-duplicates.patch"
15
+ "patches/libdatachannel-nack-duplicates.patch",
16
+ "patches/libjuice-ice-role-presence.patch"
16
17
  ],
17
18
  "scripts": {
18
19
  "build": "bun run typecheck && tsup",
@@ -0,0 +1,96 @@
1
+ diff --git a/src/agent.c b/src/agent.c
2
+ --- a/src/agent.c
3
+ +++ b/src/agent.c
4
+ @@ -1437,7 +1437,7 @@
5
+ return -1;
6
+
7
+ ice_candidate_pair_t *pair = entry->pair;
8
+ - if (msg->ice_controlling == msg->ice_controlled) {
9
+ + if (msg->has_ice_controlling == msg->has_ice_controlled) {
10
+ JLOG_WARN("Controlling and controlled attributes mismatch in request");
11
+ agent_send_stun_binding(agent, entry, STUN_CLASS_RESP_ERROR, 400, msg->transaction_id,
12
+ NULL);
13
+ @@ -1451,7 +1451,7 @@
14
+ // ERROR-CODE attribute with a value of 487 (Role Conflict) but retains its role.
15
+ // * If the agent's tiebreaker value is less than the contents of the ICE-CONTROLLING
16
+ // attribute, the agent switches to the controlled role.
17
+ - if (agent->mode == AGENT_MODE_CONTROLLING && msg->ice_controlling) {
18
+ + if (agent->mode == AGENT_MODE_CONTROLLING && msg->has_ice_controlling) {
19
+ JLOG_WARN("ICE role conflict (both controlling)");
20
+ if (agent->ice_tiebreaker >= msg->ice_controlling) {
21
+ JLOG_DEBUG("Asking remote peer to switch roles");
22
+ @@ -1471,7 +1471,7 @@
23
+ // * If the agent's tiebreaker value is less than the contents of the ICE-CONTROLLED
24
+ // attribute, the agent generates a Binding error response and includes an ERROR-CODE
25
+ // attribute with a value of 487 (Role Conflict) but retains its role.
26
+ - if (agent->mode == AGENT_MODE_CONTROLLED && msg->ice_controlled) {
27
+ + if (agent->mode == AGENT_MODE_CONTROLLED && msg->has_ice_controlled) {
28
+ JLOG_WARN("ICE role conflict (both controlled)");
29
+ if (agent->ice_tiebreaker >= msg->ice_controlling) {
30
+ JLOG_DEBUG("Switching to controlling role");
31
+ @@ -1485,7 +1485,7 @@
32
+ break;
33
+ }
34
+ if (msg->use_candidate) {
35
+ - if (!msg->ice_controlling) {
36
+ + if (!msg->has_ice_controlling) {
37
+ JLOG_WARN("STUN message use_candidate missing ice_controlling attribute");
38
+ agent_send_stun_binding(agent, entry, STUN_CLASS_RESP_ERROR, 400,
39
+ msg->transaction_id, NULL);
40
+ @@ -1715,6 +1715,8 @@
41
+ password = agent->remote.ice_pwd;
42
+ msg.ice_controlling = agent->mode == AGENT_MODE_CONTROLLING ? agent->ice_tiebreaker : 0;
43
+ msg.ice_controlled = agent->mode == AGENT_MODE_CONTROLLED ? agent->ice_tiebreaker : 0;
44
+ + msg.has_ice_controlling = agent->mode == AGENT_MODE_CONTROLLING;
45
+ + msg.has_ice_controlled = agent->mode == AGENT_MODE_CONTROLLED;
46
+
47
+ // RFC 8445 7.1.1. PRIORITY
48
+ // The PRIORITY attribute MUST be included in a Binding request and be set to the value
49
+ diff --git a/src/stun.c b/src/stun.c
50
+ --- a/src/stun.c
51
+ +++ b/src/stun.c
52
+ @@ -157,14 +157,14 @@
53
+ goto overflow;
54
+ pos += len;
55
+ }
56
+ - if (msg->ice_controlling) {
57
+ + if (msg->has_ice_controlling || msg->ice_controlling) {
58
+ uint64_t ice_controlling = htonll(msg->ice_controlling);
59
+ len = stun_write_attr(pos, end - pos, STUN_ATTR_ICE_CONTROLLING, &ice_controlling, 8);
60
+ if (len <= 0)
61
+ goto overflow;
62
+ pos += len;
63
+ }
64
+ - if (msg->ice_controlled) {
65
+ + if (msg->has_ice_controlled || msg->ice_controlled) {
66
+ uint64_t ice_controlled = htonll(msg->ice_controlled);
67
+ len = stun_write_attr(pos, end - pos, STUN_ATTR_ICE_CONTROLLED, &ice_controlled, 8);
68
+ if (len <= 0)
69
+ @@ -916,6 +916,7 @@
70
+ }
71
+ uint32_t *value32 = (uint32_t *)attr->value;
72
+ msg->ice_controlling = ((uint64_t)ntohl(value32[0]) << 32) | ntohl(value32[1]);
73
+ + msg->has_ice_controlling = true;
74
+ break;
75
+ }
76
+ case STUN_ATTR_ICE_CONTROLLED: {
77
+ @@ -926,6 +927,7 @@
78
+ }
79
+ uint32_t *value32 = (uint32_t *)attr->value;
80
+ msg->ice_controlled = ((uint64_t)ntohl(value32[0]) << 32) | ntohl(value32[1]);
81
+ + msg->has_ice_controlled = true;
82
+ break;
83
+ }
84
+ case STUN_ATTR_CHANNEL_NUMBER: {
85
+ diff --git a/src/stun.h b/src/stun.h
86
+ --- a/src/stun.h
87
+ +++ b/src/stun.h
88
+ @@ -319,6 +319,8 @@
89
+ uint32_t priority;
90
+ uint64_t ice_controlling;
91
+ uint64_t ice_controlled;
92
+ + bool has_ice_controlling;
93
+ + bool has_ice_controlled;
94
+ bool use_candidate;
95
+ addr_record_t mapped;
96
+
@@ -19,6 +19,7 @@ git -C "$source_dir" checkout -q --detach FETCH_HEAD
19
19
  git -C "$source_dir" submodule update --init --recursive --depth 1
20
20
  git -C "$source_dir" apply "$script_dir/../patches/libdatachannel-dtls-bio.patch"
21
21
  git -C "$source_dir" apply "$script_dir/../patches/libdatachannel-nack-duplicates.patch"
22
+ git -C "$source_dir/deps/libjuice" apply "$script_dir/../patches/libjuice-ice-role-presence.patch"
22
23
 
23
24
  cmake -S "$build_root" -B "$build_root/build" -G Ninja \
24
25
  -DCMAKE_BUILD_TYPE=Release \