// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; using System.Collections; using System.Threading.Tasks; using UnityEngine; using UnityEngine.Networking; namespace Microsoft.MixedReality.WebRTC.Unity { /// /// Simple signaler for debug and testing. /// This is based on https://github.com/bengreenier/node-dss and SHOULD NOT BE USED FOR PRODUCTION. /// [AddComponentMenu("MixedReality-WebRTC/NodeDSS Signaler")] public class NodeDssSignaler : Signaler { /// /// Automatically log all errors to the Unity console. /// [Tooltip("Automatically log all errors to the Unity console")] public bool AutoLogErrors = true; /// /// Unique identifier of the local peer. /// [Tooltip("Unique identifier of the local peer")] public string LocalPeerId; /// /// Unique identifier of the remote peer. /// [Tooltip("Unique identifier of the remote peer")] public string RemotePeerId; /// /// The https://github.com/bengreenier/node-dss HTTP service address to connect to /// [Header("Server")] [Tooltip("The node-dss server to connect to")] public string HttpServerAddress = "http://127.0.0.1:3000/"; /// /// The interval (in ms) that the server is polled at /// [Tooltip("The interval (in ms) that the server is polled at")] public float PollTimeMs = 500f; /// /// Message exchanged with a node-dss server, serialized as JSON. /// /// /// The names of the fields is critical here for proper JSON serialization. /// [Serializable] private class NodeDssMessage { /// /// Separator for ICE messages. /// public const string IceSeparatorChar = "|"; /// /// Possible message types as-serialized on the wire to node-dss. /// public enum Type { /// /// An unrecognized message. /// Unknown = 0, /// /// A SDP offer message. /// Offer, /// /// A SDP answer message. /// Answer, /// /// A trickle-ice or ice message. /// Ice } /// /// Convert a message type from to . /// /// The message type as . /// The message type as a object. public static Type MessageTypeFromString(string stringType) { if (string.Equals(stringType, "offer", StringComparison.OrdinalIgnoreCase)) { return Type.Offer; } else if (string.Equals(stringType, "answer", StringComparison.OrdinalIgnoreCase)) { return Type.Answer; } throw new ArgumentException($"Unkown signaler message type '{stringType}'", "stringType"); } public static Type MessageTypeFromSdpMessageType(SdpMessageType type) { switch (type) { case SdpMessageType.Offer: return Type.Offer; case SdpMessageType.Answer: return Type.Answer; default: return Type.Unknown; } } public IceCandidate ToIceCandidate() { if (MessageType != Type.Ice) { throw new InvalidOperationException("The node-dss message it not an ICE candidate message."); } var parts = Data.Split(new string[] { IceSeparatorChar }, StringSplitOptions.RemoveEmptyEntries); // Note the inverted arguments; candidate is last in IceCandidate, but first in the node-dss wire message return new IceCandidate { SdpMid = parts[2], SdpMlineIndex = int.Parse(parts[1]), Content = parts[0] }; } public NodeDssMessage(SdpMessage message) { MessageType = MessageTypeFromSdpMessageType(message.Type); Data = message.Content; IceDataSeparator = string.Empty; } public NodeDssMessage(IceCandidate candidate) { MessageType = Type.Ice; Data = string.Join(IceSeparatorChar, candidate.Content, candidate.SdpMlineIndex.ToString(), candidate.SdpMid); IceDataSeparator = IceSeparatorChar; } /// /// The message type. /// public Type MessageType = Type.Unknown; /// /// The primary message contents. /// public string Data; /// /// The data separator needed for proper ICE serialization. /// public string IceDataSeparator; } /// /// Internal timing helper /// private float timeSincePollMs = 0f; /// /// Internal last poll response status flag /// private bool lastGetComplete = true; #region ISignaler interface /// public override Task SendMessageAsync(SdpMessage message) { return SendMessageImplAsync(new NodeDssMessage(message)); } /// public override Task SendMessageAsync(IceCandidate candidate) { return SendMessageImplAsync(new NodeDssMessage(candidate)); } #endregion private Task SendMessageImplAsync(NodeDssMessage message) { // This method needs to return a Task object which gets completed once the signaler message // has been sent. Because the implementation uses a Unity coroutine, use a reset event to // signal the task to complete from the coroutine after the message is sent. // Note that the coroutine is a Unity object so needs to be started from the main Unity app thread. // Also note that TaskCompletionSource is used as a no-result variant; there is no meaning // to the bool value. // https://stackoverflow.com/questions/11969208/non-generic-taskcompletionsource-or-alternative var tcs = new TaskCompletionSource(); _mainThreadWorkQueue.Enqueue(() => StartCoroutine(PostToServerAndWait(message, tcs))); return tcs.Task; } /// /// Unity Engine Start() hook /// /// /// https://docs.unity3d.com/ScriptReference/MonoBehaviour.Start.html /// private void Start() { if (string.IsNullOrEmpty(HttpServerAddress)) { throw new ArgumentNullException("HttpServerAddress"); } if (!HttpServerAddress.EndsWith("/")) { HttpServerAddress += "/"; } // If not explicitly set, default local ID to some unique ID generated by Unity if (string.IsNullOrEmpty(LocalPeerId)) { LocalPeerId = SystemInfo.deviceName; } } /// /// Internal helper for sending HTTP data to the node-dss server using POST /// /// the message to send private IEnumerator PostToServer(NodeDssMessage msg) { if (RemotePeerId.Length == 0) { throw new InvalidOperationException("Cannot send SDP message to remote peer; invalid empty remote peer ID."); } var data = System.Text.Encoding.UTF8.GetBytes(JsonUtility.ToJson(msg)); var www = new UnityWebRequest($"{HttpServerAddress}data/{RemotePeerId}", UnityWebRequest.kHttpVerbPOST); www.uploadHandler = new UploadHandlerRaw(data); yield return www.SendWebRequest(); if (AutoLogErrors && (www.isNetworkError || www.isHttpError)) { Debug.Log($"Failed to send message to remote peer {RemotePeerId}: {www.error}"); } } /// /// Internal helper to wrap a coroutine into a synchronous call for use inside /// a object. /// /// the message to send private IEnumerator PostToServerAndWait(NodeDssMessage message, TaskCompletionSource tcs) { yield return StartCoroutine(PostToServer(message)); const bool dummy = true; // unused tcs.SetResult(dummy); } /// /// Internal coroutine helper for receiving HTTP data from the DSS server using GET /// and processing it as needed /// /// the message private IEnumerator CO_GetAndProcessFromServer() { if (HttpServerAddress.Length == 0) { throw new InvalidOperationException("Cannot receive SDP messages from remote peer; invalid empty HTTP server address."); } if (LocalPeerId.Length == 0) { throw new InvalidOperationException("Cannot receive SDP messages from remote peer; invalid empty local peer ID."); } var www = UnityWebRequest.Get($"{HttpServerAddress}data/{LocalPeerId}"); yield return www.SendWebRequest(); if (!www.isNetworkError && !www.isHttpError) { var json = www.downloadHandler.text; var msg = JsonUtility.FromJson(json); // if the message is good if (msg != null) { // depending on what type of message we get, we'll handle it differently // this is the "glue" that allows two peers to establish a connection. DebugLogLong($"Received SDP message: type={msg.MessageType} data={msg.Data}"); switch (msg.MessageType) { case NodeDssMessage.Type.Offer: // Apply the offer coming from the remote peer to the local peer var sdpOffer = new WebRTC.SdpMessage { Type = SdpMessageType.Offer, Content = msg.Data }; PeerConnection.HandleConnectionMessageAsync(sdpOffer).ContinueWith(_ => { // If the remote description was successfully applied then immediately send // back an answer to the remote peer to acccept the offer. _nativePeer.CreateAnswer(); }, TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.RunContinuationsAsynchronously); break; case NodeDssMessage.Type.Answer: // No need to wait for completion; there is nothing interesting to do after it. var sdpAnswer = new WebRTC.SdpMessage { Type = SdpMessageType.Answer, Content = msg.Data }; _ = PeerConnection.HandleConnectionMessageAsync(sdpAnswer); break; case NodeDssMessage.Type.Ice: // this "parts" protocol is defined above, in OnIceCandidateReadyToSend listener _nativePeer.AddIceCandidate(msg.ToIceCandidate()); break; default: Debug.Log("Unknown message: " + msg.MessageType + ": " + msg.Data); break; } timeSincePollMs = PollTimeMs + 1f; //fast forward next request } else if (AutoLogErrors) { Debug.LogError($"Failed to deserialize JSON message : {json}"); } } else if (AutoLogErrors && www.isNetworkError) { Debug.LogError($"Network error trying to send data to {HttpServerAddress}: {www.error}"); } else { // This is very spammy because the node-dss protocol uses 404 as regular "no data yet" message, which is an HTTP error //Debug.LogError($"HTTP error: {www.error}"); } lastGetComplete = true; } /// protected override void Update() { // Do not forget to call the base class Update(), which processes events from background // threads to fire the callbacks implemented in this class. base.Update(); // If we have not reached our PollTimeMs value... if (timeSincePollMs <= PollTimeMs) { // ...then we keep incrementing our local counter until we do. timeSincePollMs += Time.deltaTime * 1000.0f; return; } // If we have a pending request still going, don't queue another yet. if (!lastGetComplete) { return; } // When we have reached our PollTimeMs value... timeSincePollMs = 0f; // ...begin the poll and process. lastGetComplete = false; StartCoroutine(CO_GetAndProcessFromServer()); } private void DebugLogLong(string str) { #if !UNITY_EDITOR && UNITY_ANDROID // On Android, logcat truncates to ~1000 characters, so split manually instead. const int maxLineSize = 1000; int totalLength = str.Length; int numLines = (totalLength + maxLineSize - 1) / maxLineSize; for (int i = 0; i < numLines; ++i) { int start = i * maxLineSize; int length = Math.Min(start + maxLineSize, totalLength) - start; Debug.Log(str.Substring(start, length)); } #else Debug.Log(str); #endif } } }