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 +3 -3
- package/dist/index.js +86 -35
- package/dist/stream-viewer.js +123 -50
- package/package.json +3 -2
- package/patches/libjuice-ice-role-presence.patch +96 -0
- package/scripts/build-webrtc.sh +1 -0
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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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;
|
package/dist/stream-viewer.js
CHANGED
|
@@ -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 >
|
|
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 = (
|
|
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("
|
|
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("
|
|
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("
|
|
540
|
+
useFallback("server_connection_failed", true);
|
|
488
541
|
return;
|
|
489
542
|
}
|
|
490
543
|
if (message.type === "offer") {
|
|
491
|
-
void negotiate(message.sdp).catch((error) =>
|
|
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
|
-
|
|
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("
|
|
508
|
-
else if (!message.iceServers) useFallback("
|
|
509
|
-
else if (typeof RTCPeerConnection !== "function") useFallback("
|
|
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("
|
|
572
|
+
useFallback("recent_failure");
|
|
513
573
|
} else if (!transport) {
|
|
514
574
|
showStatus("Establishing connection\u2026");
|
|
515
|
-
webrtcDeadline ??= setTimeout(() => useFallback("
|
|
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("
|
|
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 (
|
|
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.
|
|
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
|
+
|
package/scripts/build-webrtc.sh
CHANGED
|
@@ -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 \
|