diff --git a/src/domain/networking/NodeList.ts b/src/domain/networking/NodeList.ts index 5dd15cba..34fa954e 100644 --- a/src/domain/networking/NodeList.ts +++ b/src/domain/networking/NodeList.ts @@ -423,7 +423,7 @@ class NodeList extends LimitedNodeList { #startNodeHolePunch = (node: Node): void => { // C++ void startNodeHolePunch(const Node* node); // While we don't need to do hole punching per se because WebRTC handles this, we initiate opening the WebRTC data - // channel and adopt the native client's use of pings and replys to coordinate setting up communications with the + // channel and adopt the native client's use of pings and replies to coordinate setting up communications with the // assignment client. // WebRTC: Initiate opening the WebRTC data channel. diff --git a/src/domain/networking/udt/Socket.ts b/src/domain/networking/udt/Socket.ts index 1080a652..05b42a55 100644 --- a/src/domain/networking/udt/Socket.ts +++ b/src/domain/networking/udt/Socket.ts @@ -59,7 +59,7 @@ class Socket { static readonly #WEBRTCSOCKET_TO_SOCKET_STATES = [ Socket.UNCONNECTED, - Socket.UNCONNECTED, + Socket.CONNECTING, Socket.CONNECTING, Socket.CONNECTED ]; diff --git a/src/domain/networking/webrtc/WebRTCDataChannel.ts b/src/domain/networking/webrtc/WebRTCDataChannel.ts index 2a528b8e..a3602a43 100644 --- a/src/domain/networking/webrtc/WebRTCDataChannel.ts +++ b/src/domain/networking/webrtc/WebRTCDataChannel.ts @@ -10,6 +10,7 @@ import NodeType, { NodeTypeValue } from "../NodeType"; import WebRTCSignalingChannel, { SignalingMessage } from "./WebRTCSignalingChannel"; +import assert from "../../shared/assert"; type OnOpenCallback = () => void; @@ -102,14 +103,26 @@ class WebRTCDataChannel { static readonly #CONFIGURATION = { // WEBRTC TODO: Make configurable in the API. - iceServers: [{ urls: "stun:ice.vircadia.com:7337" }] + // FIXME: stun:ice.vircadia.com:7337 doesn't work for WebRTC. + // Firefox warns: "WebRTC: Using more than two STUN/TURN servers slows down discovery" + iceServers: [ + { + urls: [ + "stun:stun1.l.google.com:19302", + "stun:stun.schlund.de" + ] + } + ] }; #_nodeType = NodeType.Unassigned; + #_nodeTypeName = ""; #_signalingChannel: WebRTCSignalingChannel | null = null; #_peerConnection: RTCPeerConnection | null = null; + #_offer: RTCSessionDescriptionInit | null = null; + #_haveSetRemoteDescription = false; #_dataChannel: RTCDataChannel | null = null; #_dataChannelID = 0; #_readyState = WebRTCDataChannel.CLOSED; @@ -119,9 +132,12 @@ class WebRTCDataChannel { #_oncloseCallback: OnCloseCallback | null = null; #_onerrorCallback: OnErrorCallback | null = null; + #_DEBUG = false; + constructor(nodeType: NodeTypeValue, signalingChannel: WebRTCSignalingChannel) { this.#_nodeType = nodeType; + this.#_nodeTypeName = NodeType.getNodeTypeName(nodeType); this.#_signalingChannel = signalingChannel; this.#_readyState = WebRTCDataChannel.CONNECTING; setTimeout(() => { @@ -223,46 +239,33 @@ class WebRTCDataChannel { // Starts making a WebRTC connection. - #start(): void { + async #start(): Promise { + + assert(this.#_signalingChannel !== null); // Create new peer connection object. this.#_peerConnection = new RTCPeerConnection(WebRTCDataChannel.#CONFIGURATION); // Send ICE candidates to the domain server. this.#_peerConnection.onicecandidate = ({ candidate }) => { - if (candidate // The candidate is sometimes null for unknown reasons; don't send this. - && this.#_signalingChannel && this.#_signalingChannel.readyState === WebRTCSignalingChannel.OPEN) { - this.#_signalingChannel.send({ to: this.#_nodeType, data: candidate }); - } - }; - - // Generate an offer. - this.#_peerConnection.onnegotiationneeded = async () => { - if (!this.#_peerConnection || !this.#_signalingChannel - || this.#_signalingChannel.readyState !== WebRTCSignalingChannel.OPEN) { - return; + if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Obtained ICE candidate.`); } - try { - // Create offer. - const offer = await this.#_peerConnection.createOffer(); - await this.#_peerConnection.setLocalDescription(offer); - - // Send offer to domain server. - this.#_signalingChannel.send({ - to: this.#_nodeType, - data: { description: this.#_peerConnection.localDescription } - }); - } catch (err) { - const errorMessage = "WebRTCDataChannel: Error during offer negotiation: " + err; - console.error(errorMessage); - if (this.#_onerrorCallback) { - this.#_onerrorCallback(errorMessage); + if (candidate // The candidate is sometimes null; don't send this but do send empty string. + && this.#_signalingChannel && this.#_signalingChannel.readyState === WebRTCSignalingChannel.OPEN) { + if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Send ICE candidate.`); } + this.#_signalingChannel.send({ to: this.#_nodeType, data: { candidate } }); } }; // Observe connection state changes. this.#_peerConnection.onconnectionstatechange = () => { + if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Connection state changed:`, + this.#_peerConnection?.connectionState); + } let errorMessage = ""; switch (this.#_peerConnection ? this.#_peerConnection.connectionState : "") { case "new": @@ -272,7 +275,7 @@ class WebRTCDataChannel { break; case "connected": // The connection has become fully connected. - // However, _readyState isn't set to OPEN until the data channel has been connected. + // However, #_readyState isn't set to OPEN until the data channel has been connected. break; case "disconnected": case "failed": @@ -329,6 +332,28 @@ class WebRTCDataChannel { } }; + // Create offer. + if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Create offer.`); + } + const rtcOfferOptions = { + offerToReceiveAudio: false, + offerToReceiveVideo: false + }; + this.#_offer = await this.#_peerConnection.createOffer(rtcOfferOptions); + // Don't set the local description until we have the remote answer because setting the local description triggers ICE + // candidate gathering and the remote isn't ready to handle them yet. + this.#_haveSetRemoteDescription = false; + + // Send offer to domain server. + if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Send offer.`); + } + this.#_signalingChannel.send({ + to: this.#_nodeType, + data: { description: this.#_offer } + }); + } // start // Instigates the WebRTC connection process. @@ -356,13 +381,12 @@ class WebRTCDataChannel { return; } - // Start a new peer connection if necessary. - if (!this.#_peerConnection && (description || candidate)) { - this.#start(); - } - try { if (description) { + if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Received description.`); + } + if (!this.#_peerConnection) { const errorMessage = "WebRTCDataChannel: Peer connection is closed!"; console.error(errorMessage); @@ -372,20 +396,37 @@ class WebRTCDataChannel { return; } - // Add remote connection information to peer connection. - await this.#_peerConnection.setRemoteDescription(description); + // We got an answer. + if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Description is ${description.type}.`); + } + if (description.type === "answer" && this.#_signalingChannel) { + assert(this.#_offer !== null); + + // The server is ready to handle ICE candidates so set we can set the local description now. + await this.#_peerConnection.setLocalDescription(this.#_offer); - // We got an offer; reply with an answer. - if (description.type === "offer" && this.#_signalingChannel) { - await this.#_peerConnection.setLocalDescription(description); - this.#_signalingChannel.send({ - description: this.#_peerConnection.localDescription - }); + await this.#_peerConnection.setRemoteDescription(description); + this.#_haveSetRemoteDescription = true; + } else { + const errorMessage = `WebRTCDataChannel: Unexpected answer! ${description.type}`; + console.error(errorMessage); + if (this.#_onerrorCallback) { + this.#_onerrorCallback(errorMessage); + } } } else if (candidate) { - // Add ICE candidate to peer connection. - if (this.#_peerConnection) { + // Add ICE candidate to the peer connection. + // Don't set unless the remote description has been set, otherwise an error is generated. The first ICE + // candidate from the server may arrive before the remote description has been set because of the delay + // introduced by setting the local description just before setting the remote description. + if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Received ICE candidate.`); + } + if (this.#_peerConnection && this.#_haveSetRemoteDescription) { await this.#_peerConnection.addIceCandidate(candidate); + } else if (this.#_DEBUG) { + console.debug(`[webrtc] [${this.#_nodeTypeName}] Skipped adding ICE candidate.`); } } else if (echo) { // Ignore signaling channel "echo" messages. @@ -409,7 +450,7 @@ class WebRTCDataChannel { }); // Start the WebRTC connection process. - this.#start(); + void this.#start(); } // #connect diff --git a/src/domain/networking/webrtc/WebRTCSocket.ts b/src/domain/networking/webrtc/WebRTCSocket.ts index 6003ed6f..c668e0f1 100644 --- a/src/domain/networking/webrtc/WebRTCSocket.ts +++ b/src/domain/networking/webrtc/WebRTCSocket.ts @@ -120,10 +120,9 @@ class WebRTCSocket { // Fall through. } } - if (this.#_webrtcSignalingChannel !== null) { + if (nodeType === NodeTypeValue.DomainServer && this.#_webrtcSignalingChannel !== null) { switch (this.#_webrtcSignalingChannel.readyState) { case WebRTCSignalingChannel.OPEN: - return WebRTCSocket.SIGNALING; case WebRTCSignalingChannel.CONNECTING: return WebRTCSocket.SIGNALING; default: diff --git a/tests/domain/networking/webrtc/WebRTCSocket.integration.test.js b/tests/domain/networking/webrtc/WebRTCSocket.integration.test.js index 39e67740..fd2c923a 100644 --- a/tests/domain/networking/webrtc/WebRTCSocket.integration.test.js +++ b/tests/domain/networking/webrtc/WebRTCSocket.integration.test.js @@ -32,7 +32,7 @@ describe("WebRTCSocket - integration tests", () => { expect(webrtcSocket.state(TestConfig.SERVER_SIGNALING_SOCKET_URL, NodeType.DomainServer)) .toBe(WebRTCSocket.CONNECTED); expect(webrtcSocket.state(TestConfig.SERVER_SIGNALING_SOCKET_URL, NodeType.AudioMixer)) - .toBe(WebRTCSocket.SIGNALING); + .toBe(WebRTCSocket.UNCONNECTED); expect(webrtcSocket.state(TestConfig.SERVER_SIGNALING_SOCKET_URL + "1", NodeType.DomainServer)) .toBe(WebRTCSocket.UNCONNECTED); webrtcSocket.abort();