diff --git a/Documentation~/README.md b/Documentation~/README.md new file mode 100644 index 0000000..30c1854 --- /dev/null +++ b/Documentation~/README.md @@ -0,0 +1,149 @@ +# com.utilities.websockets + +[![Discord](https://img.shields.io/discord/855294214065487932.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/xQgMW9ufN4) [![openupm](https://img.shields.io/npm/v/com.utilities.websockets?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/com.utilities.websockets/) [![openupm](https://img.shields.io/badge/dynamic/json?color=brightgreen&label=downloads&query=%24.downloads&suffix=%2Fmonth&url=https%3A%2F%2Fpackage.openupm.com%2Fdownloads%2Fpoint%2Flast-month%2Fcom.utilities.websockets)](https://openupm.com/packages/com.utilities.websockets/) + +A simple websocket package for the [Unity](https://unity.com/) Game Engine. + +## Installing + +Requires Unity 2021.3 LTS or higher. + +The recommended installation method is though the unity package manager and [OpenUPM](https://openupm.com/packages/com.utilities.websockets). + +### Via Unity Package Manager and OpenUPM + +- Open your Unity project settings +- Select the `Package Manager` +![scoped-registries](images/package-manager-scopes.png) +- Add the OpenUPM package registry: + - Name: `OpenUPM` + - URL: `https://package.openupm.com` + - Scope(s): + - `com.utilities` +- Open the Unity Package Manager window +- Change the Registry from Unity to `My Registries` +- Add the `Utilities.Websockets` package + +### Via Unity Package Manager and Git url + +- Open your Unity Package Manager +- Add package from git url: `https://github.com/RageAgainstThePixel/com.utilities.websockets.git#upm` + > Note: this repo has dependencies on other repositories! You are responsible for adding these on your own. + - [com.utilities.async](https://github.com/RageAgainstThePixel/com.utilities.async) + +--- + +## Documentation + +### Table Of Contents + +- [Connect to a Server](#connect-to-a-server) +- [Handling Events](#handling-events) + - [OnOpen](#onopen) + - [OnMessage](#onmessage) + - [OnError](#onerror) + - [OnClose](#onclose) +- [Sending Messages](#sending-messages) + - [Text](#sending-text) + - [Binary](#sending-binary) +- [Disconnect from a Server](#disconnect-from-a-server) + +### Connect to a Server + +To setup a new connection, create a new instance of WebSocket and subscribe to event callbacks, and call `Connect` or `ConnectAsync` methods. + +> Note: WebSocket implements `IDisposable` and should be properly disposed after use! + +```csharp +var address = "wss://echo.websocket.events"; +using var socket = new WebSocket(address); +socket.OnOpen += () => Debug.Log($"Connection Established @ {address}"); +socket.OnMessage += (dataFrame) => { + switch (dataFrame.Type) + { + case OpCode.Text: + AddLog($"<- Received: {dataFrame.Text}"); + break; + case OpCode.Binary: + AddLog($"<- Received: {dataFrame.Data.Length} Bytes"); + break; + } +}; +socket.OnError += (exception) => Debug.LogException(exception); +socket.OnClose += (code, reason) => Debug.Log($"Connection Closed: {code} {reason}"); +socket.Connect(); +``` + +### Handling Events + +You can subscribe to the `OnOpen`, `OnMessage`, `OnError`, and `OnClose` events to handle respective situations: + +#### OnOpen + +Event triggered when the WebSocket connection has been established. + +```csharp +socket.OnOpen += () => Debug.Log("Connection Established!"); +``` + +#### OnMessage + +Event triggered when the WebSocket receives a message. The callback contains a data frame, which can be either text or binary. + +```csharp +socket.OnMessage += (dataFrame) => { + switch (dataFrame.Type) + { + case OpCode.Text: + AddLog($"<- Received: {dataFrame.Text}"); + break; + case OpCode.Binary: + AddLog($"<- Received: {dataFrame.Data.Length} Bytes"); + break; + } +}; +``` + +#### OnError + +Event triggered when the WebSocket raises an error. The callback contains an exception which can be handled, re-thrown, or logged. + +```csharp +socket.OnError += (exception) => Debug.LogException(exception); +``` + +#### OnClose + +Event triggered when the WebSocket connection has been closed. The callback contains the close code and reason. + +```csharp +socket.OnClose += (code, reason) => Debug.Log($"Connection Closed: {code} {reason}"); +``` + +### Sending Messages + +#### Sending Text + +Perfect for sending json payloads and other text messages. + +```csharp +await socket.SendAsync("{\"message\":\"Hello World!\"}"); +``` + +#### Sending Binary + +Perfect for sending binary data and files. + +```csharp +var bytes = System.Text.Encoding.UTF8.GetBytes("Hello World!"); +await socket.SendAsync(bytes); +``` + +### Disconnect from a Server + +To disconnect from the server, use `Close` or `CloseAsync` methods and dispose of the WebSocket. + +```csharp +socket.Close(); +socket.Dispose(); +``` diff --git a/Documentation~/images/package-manager-scopes.png b/Documentation~/images/package-manager-scopes.png new file mode 100644 index 0000000..999ac4a Binary files /dev/null and b/Documentation~/images/package-manager-scopes.png differ diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..bb383ac --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 829a2a29ce04aed4aa31a5d54660c44c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/AssemblyInfo.cs b/Editor/AssemblyInfo.cs new file mode 100644 index 0000000..26b0717 --- /dev/null +++ b/Editor/AssemblyInfo.cs @@ -0,0 +1 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. diff --git a/Editor/AssemblyInfo.cs.meta b/Editor/AssemblyInfo.cs.meta new file mode 100644 index 0000000..d12d33f --- /dev/null +++ b/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20b20f831b318e5459f672cb68f03cf5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Utilities.Websockets.Editor.asmdef b/Editor/Utilities.Websockets.Editor.asmdef new file mode 100644 index 0000000..0cde049 --- /dev/null +++ b/Editor/Utilities.Websockets.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Utilities.WebSockets.Editor", + "rootNamespace": "Utilities.WebSockets.Editor", + "references": [ + "Utilities.WebSockets" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Editor/Utilities.Websockets.Editor.asmdef.meta b/Editor/Utilities.Websockets.Editor.asmdef.meta new file mode 100644 index 0000000..a05b158 --- /dev/null +++ b/Editor/Utilities.Websockets.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: edb38984470ed7b42b651ccb73c1ff02 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ae10890 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 RageAgainstThePixel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE.md.meta b/LICENSE.md.meta new file mode 100644 index 0000000..24bb1a1 --- /dev/null +++ b/LICENSE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1873865bc54a29a4fb38b402c58b00a2 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..c880f55 --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5fcb3c3f74f4fdc47bc5ffec866b8f25 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/AssemblyInfo.cs b/Runtime/AssemblyInfo.cs new file mode 100644 index 0000000..26b0717 --- /dev/null +++ b/Runtime/AssemblyInfo.cs @@ -0,0 +1 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. diff --git a/Runtime/AssemblyInfo.cs.meta b/Runtime/AssemblyInfo.cs.meta new file mode 100644 index 0000000..a3b791b --- /dev/null +++ b/Runtime/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1916059d33c5f6246b3e05e32a48a1ed +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/CloseStatusCode.cs b/Runtime/CloseStatusCode.cs new file mode 100644 index 0000000..51035ff --- /dev/null +++ b/Runtime/CloseStatusCode.cs @@ -0,0 +1,80 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Utilities.WebSockets +{ + /// + /// When closing an established connection (e.g., when sending a Close frame, after the opening handshake has completed), + /// an endpoint MAY indicate a reason for closure. + /// + /// + /// The values of this enumeration are defined in . + /// + public enum CloseStatusCode : ushort + { + /// + /// Indicates a normal closure, meaning that the purpose for which the connection was established has been fulfilled. + /// + Normal = 1000, + /// + /// Indicates that an endpoint is "going away", such as a server going down or a browser having navigated away from a page. + /// + GoingAway = 1001, + /// + /// Indicates that an endpoint is terminating the connection due to a protocol error. + /// + ProtocolError = 1002, + /// + /// Indicates that an endpoint is terminating the connection because it has received a type of data it cannot accept + /// (e.g., an endpoint that understands only text data MAY send this if it receives a binary message). + /// + UnsupportedData = 1003, + /// + /// Reserved and MUST NOT be set as a status code in a Close control frame by an endpoint. + /// The specific meaning might be defined in the future. + /// + Reserved = 1004, + /// + /// Reserved and MUST NOT be set as a status code in a Close control frame by an endpoint. + /// It is designated for use in applications expecting a status code to indicate that no status code was actually present. + /// + NoStatus = 1005, + /// + /// Reserved and MUST NOT be set as a status code in a Close control frame by an endpoint. + /// It is designated for use in applications expecting a status code to indicate that the connection was closed abnormally, + /// e.g., without sending or receiving a Close control frame. + /// + AbnormalClosure = 1006, + /// + /// Indicates that an endpoint is terminating the connection because it has received data within a message + /// that was not consistent with the type of the message. + /// + InvalidPayloadData = 1007, + /// + /// Indicates that an endpoint is terminating the connection because it received a message that violates its policy. + /// This is a generic status code that can be returned when there is no other more suitable status code (e.g., 1003 or 1009) + /// or if there is a need to hide specific details about the policy. + /// + PolicyViolation = 1008, + /// + /// Indicates that an endpoint is terminating the connection because it has received a message that is too big for it to process. + /// + TooBigToProcess = 1009, + /// + /// Indicates that an endpoint (client) is terminating the connection because it has expected the server to negotiate + /// one or more extension, but the server didn't return them in the response message of the WebSocket handshake. + /// The list of extensions that are needed SHOULD appear in the /reason/ part of the Close frame. Note that this status code + /// is not used by the server, because it can fail the WebSocket handshake instead. + /// + MandatoryExtension = 1010, + /// + /// Indicates that a server is terminating the connection because it encountered an unexpected condition that prevented it from fulfilling the request. + /// + ServerError = 1011, + /// + /// Reserved and MUST NOT be set as a status code in a Close control frame by an endpoint. + /// It is designated for use in applications expecting a status code to indicate that the connection was closed due to a failure to perform a TLS handshake + /// (e.g., the server certificate can't be verified). + /// + TlsHandshakeFailure = 1015 + } +} diff --git a/Runtime/CloseStatusCode.cs.meta b/Runtime/CloseStatusCode.cs.meta new file mode 100644 index 0000000..a00f26c --- /dev/null +++ b/Runtime/CloseStatusCode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9b749be3e094b1b48b5233eba7710b23 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/DataFrame.cs b/Runtime/DataFrame.cs new file mode 100644 index 0000000..14ec72b --- /dev/null +++ b/Runtime/DataFrame.cs @@ -0,0 +1,24 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; + +namespace Utilities.WebSockets +{ + public class DataFrame + { + public OpCode Type { get; } + + public ReadOnlyMemory Data { get; } + + public string Text { get; } + + public DataFrame(OpCode type, ReadOnlyMemory data) + { + Type = type; + Data = data; + Text = type == OpCode.Text + ? System.Text.Encoding.UTF8.GetString(data.Span) + : string.Empty; + } + } +} diff --git a/Runtime/DataFrame.cs.meta b/Runtime/DataFrame.cs.meta new file mode 100644 index 0000000..872672f --- /dev/null +++ b/Runtime/DataFrame.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 196ef085c1e622d4992be519a21fad3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/IWebSocket.cs b/Runtime/IWebSocket.cs new file mode 100644 index 0000000..ccb7dd3 --- /dev/null +++ b/Runtime/IWebSocket.cs @@ -0,0 +1,85 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Utilities.WebSockets +{ + public interface IWebSocket : IDisposable + { + /// + /// Occurs when the connection has been established. + /// + event Action OnOpen; + + /// + /// Occurs when the receives a message. + /// + event Action OnMessage; + + /// + /// Occurs when the raises an error. + /// + event Action OnError; + + /// + /// Occurs when the connection has been closed. + /// + event Action OnClose; + + /// + /// The address of the . + /// + Uri Address { get; } + + /// + /// The sub-protocols used by the . + /// + IReadOnlyList SubProtocols { get; } + + /// + /// The current state of the . + /// + State State { get; } + + /// + /// Connect to the server. + /// + void Connect(); + + /// + /// Connect to the server asynchronously. + /// + /// Optional, . + Task ConnectAsync(CancellationToken cancellationToken = default); + + /// + /// Send a text message to the . + /// + /// The text message to send. + /// Optional, . + Task SendAsync(string text, CancellationToken cancellationToken = default); + + /// + /// Send a binary message to the . + /// + /// The binary message to send. + /// Optional, . + Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default); + + /// + /// Close the . + /// + void Close(); + + /// + /// Close the asynchronously. + /// + /// The close status code. + /// The reason for closing the connection. + /// Optional, . + Task CloseAsync(CloseStatusCode code = CloseStatusCode.Normal, string reason = "", CancellationToken cancellationToken = default); + } +} diff --git a/Runtime/IWebSocket.cs.meta b/Runtime/IWebSocket.cs.meta new file mode 100644 index 0000000..aefe1f7 --- /dev/null +++ b/Runtime/IWebSocket.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 251bd742d58dd8f48a56394e5586a74c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/OpCode.cs b/Runtime/OpCode.cs new file mode 100644 index 0000000..a9f2ecd --- /dev/null +++ b/Runtime/OpCode.cs @@ -0,0 +1,10 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Utilities.WebSockets +{ + public enum OpCode + { + Text, + Binary + } +} diff --git a/Runtime/OpCode.cs.meta b/Runtime/OpCode.cs.meta new file mode 100644 index 0000000..21b21ed --- /dev/null +++ b/Runtime/OpCode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e0325f235b52ed04fb6eab828f85b3ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Plugins.meta b/Runtime/Plugins.meta new file mode 100644 index 0000000..18508e5 --- /dev/null +++ b/Runtime/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 150fe18e639eaf74aba90fe3723ddb84 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Plugins/WebSocket.jslib b/Runtime/Plugins/WebSocket.jslib new file mode 100644 index 0000000..d1d39ce --- /dev/null +++ b/Runtime/Plugins/WebSocket.jslib @@ -0,0 +1,247 @@ +var UnityWebSocketLibrary = { + /** + * Pointer index for WebSocket objects. + */ + $ptrIndex: 0, + /** + * Array of instanced WebSocket objects. + */ + $webSockets: [], + /** + * Create a new WebSocket instance and adds it to the $webSockets array. + * @param {string} url - The URL to which to connect. + * @param {string[]} subProtocols - An array of strings that indicate the sub-protocols the client is willing to speak. + * @returns {number} - A pointer to the WebSocket instance. + * @param {function} onOpenCallback - The callback function. WebSocket_OnOpenDelegate(IntPtr websocketPtr) in C#. + * @param {function} onMessageCallback - The callback function. WebSocket_OnMessageDelegate(IntPtr websocketPtr, IntPtr data, int length, int type) in C#. + * @param {function} onErrorCallback - The callback function. WebSocket_OnErrorDelegate(IntPtr websocketPtr, IntPtr messagePtr) in C#. + * @param {function} onCloseCallback - The callback function. WebSocket_OnCloseDelegate(IntPtr websocketPtr, int code, IntPtr reasonPtr) in C#. + */ + WebSocket_Create: function (url, subProtocols, onOpenCallback, onMessageCallback, onErrorCallback, onCloseCallback) { + var urlStr = UTF8ToString(url); + + try { + var subProtocolsStr = UTF8ToString(subProtocols); + var subProtocolsArr = subProtocolsStr ? subProtocolsStr.split(',') : undefined; + + for (var i = 0; i < webSockets.length; i++) { + var instance = webSockets[i]; + + if (instance !== undefined && instance.url !== undefined && instance.url === urlStr) { + console.error('WebSocket connection already exists for URL: ', urlStr); + return 0; + } + } + + var socketPtr = ++ptrIndex; + webSockets[socketPtr] = { + socket: null, + url: urlStr, + onOpenCallback: onOpenCallback, + onMessageCallback: onMessageCallback, + onErrorCallback: onErrorCallback, + onCloseCallback: onCloseCallback + }; + + if (subProtocolsArr) { + webSockets[socketPtr].subProtocols = subProtocolsArr; + } + + // console.log('Created WebSocket object with websocketPtr: ', socketPtr, ' for URL: ', urlStr, ' and sub-protocols: ', subProtocolsArr) + return socketPtr; + } catch (error) { + console.error('Error creating WebSocket object for URL: ', urlStr, ' Error: ', error); + return 0; + } + }, + /** + * Get the current state of the WebSocket connection. + * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#. + * @returns {number} - The current state of the WebSocket connection. + */ + WebSocket_GetState: function (socketPtr) { + try { + var instance = webSockets[socketPtr]; + + if (!instance || !instance.socket) { + return 0; + } + + return instance.socket.readyState; + } catch (error) { + console.error('Error getting WebSocket state for websocketPtr: ', socketPtr, ' Error: ', error); + return 3; + } + }, + /** + * Connect the WebSocket connection. + * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#. + */ + WebSocket_Connect: function (socketPtr) { + try { + var instance = webSockets[socketPtr]; + + if (!instance.subProtocols || instance.subProtocols.length === 0) { + instance.socket = new WebSocket(instance.url); + } else { + instance.socket = new WebSocket(instance.url, instance.subProtocols); + } + + instance.socket.binaryType = 'arraybuffer'; + instance.socket.onopen = function () { + try { + // console.log('WebSocket connection opened for websocketPtr: ', socketPtr); + Module.dynCall_vi(instance.onOpenCallback, socketPtr); + } catch (error) { + console.error('Error calling onOpen callback for websocketPtr: ', socketPtr, ' Error: ', error); + } + }; + instance.socket.onmessage = function (event) { + try { + // console.log('Received message for websocketPtr: ', socketPtr, ' with data: ', event.data); + if (event.data instanceof ArrayBuffer) { + var array = new Uint8Array(event.data); + var buffer = Module._malloc(array.length); + writeArrayToMemory(array, buffer); + + try { + Module.dynCall_viiii(instance.onMessageCallback, socketPtr, buffer, array.length, 1); + } finally { + Module._free(buffer); + } + } else if (typeof event.data === 'string') { + var length = lengthBytesUTF8(event.data) + 1; + var buffer = Module._malloc(length); + stringToUTF8(event.data, buffer, length); + + try { + Module.dynCall_viiii(instance.onMessageCallback, socketPtr, buffer, length, 0); + } finally { + Module._free(buffer); + } + } else { + console.error('Error parsing message for websocketPtr: ', socketPtr, ' with data: ', event.data); + } + } catch (error) { + console.error('Error calling onMessage callback for websocketPtr: ', socketPtr, ' Error: ', error); + } + }; + instance.socket.onerror = function (event) { + try { + console.error('WebSocket error for websocketPtr: ', socketPtr, ' with message: ', event); + var json = JSON.stringify(event); + var length = lengthBytesUTF8(json) + 1; + var buffer = Module._malloc(length); + stringToUTF8(json, buffer, length); + + try { + Module.dynCall_vii(instance.onErrorCallback, socketPtr, buffer); + } finally { + Module._free(buffer); + } + } catch (error) { + console.error('Error calling onError callback for websocketPtr: ', socketPtr, ' Error: ', error); + } + }; + instance.socket.onclose = function (event) { + try { + // console.log('WebSocket connection closed for websocketPtr: ', socketPtr, ' with code: ', event.code, ' and reason: ', event.reason); + var length = lengthBytesUTF8(event.reason) + 1; + var buffer = Module._malloc(length); + stringToUTF8(event.reason, buffer, length); + + try { + Module.dynCall_viii(instance.onCloseCallback, socketPtr, event.code, buffer); + } finally { + Module._free(buffer); + } + } catch (error) { + console.error('Error calling onClose callback for websocketPtr: ', socketPtr, ' Error: ', error); + } + }; + // console.log('Connecting WebSocket connection for websocketPtr: ', socketPtr); + } catch (error) { + console.error('Error connecting WebSocket connection for websocketPtr: ', socketPtr, ' Error: ', error); + } + }, + /** + * Send data to the WebSocket connection. + * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#. + * @param data - A pointer to the data to send. + * @param length - The length of the data to send. + */ + WebSocket_SendData: function (socketPtr, data, length) { + try { + var instance = webSockets[socketPtr]; + + if (!instance || !instance.socket || instance.socket.readyState !== 1) { + console.error('WebSocket connection does not exist for websocketPtr: ', socketPtr); + return; + } + + // console.log('Sending message to WebSocket connection for websocketPtr: ', socketPtr, ' with data: ', data, ' and length: ', length); + instance.socket.send(buffer.slice(data, data + length)); + } catch (error) { + console.error('Error sending message to WebSocket connection for websocketPtr: ', socketPtr, ' Error: ', error); + } + }, + /** + * Send a string to the WebSocket connection. + * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#. + * @param data - The string to send. + */ + WebSocket_SendString: function (socketPtr, data) { + try { + var instance = webSockets[socketPtr]; + + if (!instance || !instance.socket || instance.socket.readyState !== 1) { + console.error('WebSocket connection does not exist for websocketPtr: ', socketPtr); + return; + } + + var dataStr = UTF8ToString(data); + // console.log('Sending message to WebSocket connection for websocketPtr: ', socketPtr, ' with data: ', dataStr); + instance.socket.send(dataStr); + } catch (error) { + console.error('Error sending message to WebSocket connection for websocketPtr: ', socketPtr, ' Error: ', error); + } + }, + /** + * Close the WebSocket connection. + * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#. + * @param code - The status code for the close. + * @param reason - The reason for the close. + */ + WebSocket_Close: function (socketPtr, code, reason) { + try { + var instance = webSockets[socketPtr]; + + if (!instance || !instance.socket || instance.socket.readyState >= 2) { + console.error('WebSocket connection already closed for websocketPtr: ', socketPtr); + return; + } + + var reasonStr = UTF8ToString(reason); + // console.log('Closing WebSocket connection for websocketPtr: ', socketPtr, ' with code: ', code, ' and reason: ', reasonStr); + instance.socket.close(code, reasonStr); + } catch (error) { + console.error('Error closing WebSocket connection for websocketPtr: ', socketPtr, ' Error: ', error); + } + }, + /** + * Destroy a WebSocket object. + * @param socketPtr - A pointer to the WebSocket object. IntPtr in C#. + */ + WebSocket_Dispose: function (socketPtr) { + try { + // console.log('Disposing WebSocket object with websocketPtr: ', socketPtr); + delete webSockets[socketPtr]; + } catch (error) { + console.error('Error disposing WebSocket object with websocketPtr: ', socketPtr, ' Error: ', error); + } + } +}; + +autoAddDeps(UnityWebSocketLibrary, '$ptrIndex'); +autoAddDeps(UnityWebSocketLibrary, '$webSockets'); +mergeInto(LibraryManager.library, UnityWebSocketLibrary); diff --git a/Runtime/Plugins/WebSocket.jslib.meta b/Runtime/Plugins/WebSocket.jslib.meta new file mode 100644 index 0000000..b0d60b5 --- /dev/null +++ b/Runtime/Plugins/WebSocket.jslib.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 0989d70b042875249a815faa52ebd8ce +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + WebGL: WebGL + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/State.cs b/Runtime/State.cs new file mode 100644 index 0000000..ddacc76 --- /dev/null +++ b/Runtime/State.cs @@ -0,0 +1,32 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; + +namespace Utilities.WebSockets +{ + /// + /// Indicates the state of the + /// + /// + /// The values of this enumeration are defined in + /// + public enum State : ushort + { + /// + /// The connection has not yet been established. + /// + Connecting = 0, + /// + /// The connection has been established and communication is possible. + /// + Open = 1, + /// + /// The connection is going through the closing handshake or close has been requested. + /// + Closing = 2, + /// + /// The connection has been closed or could not be opened. + /// + Closed = 3 + } +} diff --git a/Runtime/State.cs.meta b/Runtime/State.cs.meta new file mode 100644 index 0000000..38929d1 --- /dev/null +++ b/Runtime/State.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca7645d177c3b1a4dab2d88dc2da8f56 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Utilities.Websockets.asmdef b/Runtime/Utilities.Websockets.asmdef new file mode 100644 index 0000000..6757be2 --- /dev/null +++ b/Runtime/Utilities.Websockets.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Utilities.WebSockets", + "rootNamespace": "Utilities.WebSockets", + "references": [ + "GUID:a6609af893242c7438d701ddd4cce46a" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Runtime/Utilities.Websockets.asmdef.meta b/Runtime/Utilities.Websockets.asmdef.meta new file mode 100644 index 0000000..2396c7d --- /dev/null +++ b/Runtime/Utilities.Websockets.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9fb4e1e06cb4c804ebfb0cff2b90e6d3 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WebSocket.cs b/Runtime/WebSocket.cs new file mode 100644 index 0000000..c294701 --- /dev/null +++ b/Runtime/WebSocket.cs @@ -0,0 +1,286 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +#if !PLATFORM_WEBGL || UNITY_EDITOR + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using Utilities.Async; + +namespace Utilities.WebSockets +{ + public class WebSocket : IWebSocket + { + public WebSocket(string url, IReadOnlyList subProtocols = null) + : this(new Uri(url), subProtocols) + { + } + + public WebSocket(Uri uri, IReadOnlyList subProtocols = null) + { + var protocol = uri.Scheme; + + if (!protocol.Equals("ws") && !protocol.Equals("wss")) + { + throw new ArgumentException($"Unsupported protocol: {protocol}"); + } + + Address = uri; + SubProtocols = subProtocols ?? new List(); + _socket = new ClientWebSocket(); + RunMessageQueue(); + } + + private async void RunMessageQueue() + { + while (_semaphore != null) + { + // syncs with update loop + await Awaiters.UnityMainThread; + + while (_events.TryDequeue(out var action)) + { + try + { + action.Invoke(); + } + catch (Exception e) + { + Debug.LogException(e); + OnError?.Invoke(e); + } + } + } + } + + ~WebSocket() + { + Dispose(false); + } + + #region IDisposable + + private void Dispose(bool disposing) + { + if (disposing) + { + lock (_lock) + { + if (State == State.Open) + { + CloseAsync().Wait(); + } + + _socket?.Dispose(); + _socket = null; + + _lifetimeCts?.Cancel(); + _lifetimeCts?.Dispose(); + _lifetimeCts = null; + + _semaphore?.Dispose(); + _semaphore = null; + } + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + #endregion IDisposable + + /// + public event Action OnOpen; + + /// + public event Action OnMessage; + + /// + public event Action OnError; + + /// + public event Action OnClose; + + /// + public Uri Address { get; } + + /// + public IReadOnlyList SubProtocols { get; } + + /// + public State State => _socket?.State switch + { + WebSocketState.Connecting => State.Connecting, + WebSocketState.Open => State.Open, + WebSocketState.CloseSent or WebSocketState.CloseReceived => State.Closing, + _ => State.Closed + }; + + private object _lock = new(); + private ClientWebSocket _socket; + private SemaphoreSlim _semaphore = new(1, 1); + private CancellationTokenSource _lifetimeCts; + private readonly ConcurrentQueue _events = new(); + + /// + public async void Connect() + => await ConnectAsync(); + + /// + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + try + { + if (State == State.Open) + { + Debug.LogWarning("Websocket is already open!"); + return; + } + + _lifetimeCts?.Cancel(); + _lifetimeCts?.Dispose(); + _lifetimeCts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token, cancellationToken); + + foreach (var subProtocol in SubProtocols) + { + _socket.Options.AddSubProtocol(subProtocol); + } + + await _socket.ConnectAsync(Address, cts.Token).ConfigureAwait(false); + _events.Enqueue(() => OnOpen?.Invoke()); + var buffer = new Memory(new byte[8192]); + + while (State == State.Open) + { + ValueWebSocketReceiveResult result; + using var stream = new MemoryStream(); + + do + { + result = await _socket.ReceiveAsync(buffer, cts.Token).ConfigureAwait(false); + stream.Write(buffer.Span[..result.Count]); + } while (!result.EndOfMessage); + + await stream.FlushAsync(cts.Token).ConfigureAwait(false); + var memory = new ReadOnlyMemory(stream.GetBuffer(), 0, (int)stream.Length); + + if (result.MessageType != WebSocketMessageType.Close) + { + _events.Enqueue(() => OnMessage?.Invoke(new DataFrame((OpCode)(int)result.MessageType, memory))); + } + else + { + await CloseAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); + break; + } + } + + try + { + await _semaphore.WaitAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + _semaphore.Release(); + } + } + catch (Exception e) + { + switch (e) + { + case TaskCanceledException: + case OperationCanceledException: + break; + default: + Debug.LogException(e); + _events.Enqueue(() => OnError?.Invoke(e)); + _events.Enqueue(() => OnClose?.Invoke(CloseStatusCode.AbnormalClosure, e.Message)); + break; + } + } + } + + /// + public async Task SendAsync(string text, CancellationToken cancellationToken = default) + => await Internal_SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text, cancellationToken); + + /// + public async Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default) + => await Internal_SendAsync(data, WebSocketMessageType.Binary, cancellationToken); + + private async Task Internal_SendAsync(ArraySegment data, WebSocketMessageType opCode, CancellationToken cancellationToken) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token, cancellationToken); + await _semaphore.WaitAsync(cts.Token).ConfigureAwait(false); + + if (State != State.Open) + { + throw new InvalidOperationException("WebSocket is not ready!"); + } + + await _socket.SendAsync(data, opCode, true, cts.Token).ConfigureAwait(false); + } + catch (Exception e) + { + switch (e) + { + case TaskCanceledException: + case OperationCanceledException: + break; + default: + Debug.LogException(e); + _events.Enqueue(() => OnError?.Invoke(e)); + break; + } + } + finally + { + _semaphore.Release(); + } + } + + /// + public async void Close() + => await CloseAsync(); + + /// + public async Task CloseAsync(CloseStatusCode code = CloseStatusCode.Normal, string reason = "", CancellationToken cancellationToken = default) + { + try + { + if (State == State.Open) + { + await _socket.CloseAsync((WebSocketCloseStatus)(int)code, reason, cancellationToken).ConfigureAwait(false); + _events.Enqueue(() => OnClose?.Invoke(code, reason)); + } + } + catch (Exception e) + { + switch (e) + { + case TaskCanceledException: + case OperationCanceledException: + break; + default: + Debug.LogException(e); + _events.Enqueue(() => OnError?.Invoke(e)); + break; + } + } + } + } +} +#endif // !PLATFORM_WEBGL || UNITY_EDITOR diff --git a/Runtime/WebSocket.cs.meta b/Runtime/WebSocket.cs.meta new file mode 100644 index 0000000..bbc7a89 --- /dev/null +++ b/Runtime/WebSocket.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 21cd68194448d4542bb0ce2ed9b1acd5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WebSocket_WebGL.cs b/Runtime/WebSocket_WebGL.cs new file mode 100644 index 0000000..18e3717 --- /dev/null +++ b/Runtime/WebSocket_WebGL.cs @@ -0,0 +1,264 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +#if PLATFORM_WEBGL && !UNITY_EDITOR + +using AOT; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using Utilities.Async; + +namespace Utilities.WebSockets +{ + public class WebSocket : IWebSocket + { + public WebSocket(string url, IReadOnlyList subProtocols = null) + : this(new Uri(url), subProtocols) + { + } + + public WebSocket(Uri uri, IReadOnlyList subProtocols = null) + { + var protocol = uri.Scheme; + + if (!protocol.Equals("ws") && !protocol.Equals("wss")) + { + throw new ArgumentException($"Unsupported protocol: {protocol}"); + } + + Address = uri; + SubProtocols = subProtocols ?? new List(); + _socket = WebSocket_Create(uri.ToString(), string.Join(',', SubProtocols), WebSocket_OnOpen, WebSocket_OnMessage, WebSocket_OnError, WebSocket_OnClose); + + if (_socket == IntPtr.Zero || !_sockets.TryAdd(_socket, this)) + { + throw new InvalidOperationException("Failed to create WebSocket instance!"); + } + + RunMessageQueue(); + } + + ~WebSocket() + { + Dispose(false); + } + + private async void RunMessageQueue() + { + while (_semaphore != null) + { + // syncs with update loop + await Awaiters.UnityMainThread; + + while (_events.TryDequeue(out var action)) + { + try + { + action.Invoke(); + } + catch (Exception e) + { + Debug.LogException(e); + OnError?.Invoke(e); + } + } + } + } + + #region IDisposable + + private void Dispose(bool disposing) + { + if (disposing) + { + lock (_lock) + { + if (State == State.Open) + { + CloseAsync().Wait(); + } + + WebSocket_Dispose(_socket); + _socket = IntPtr.Zero; + + _lifetimeCts?.Cancel(); + _lifetimeCts?.Dispose(); + _lifetimeCts = null; + + _semaphore?.Dispose(); + _semaphore = null; + } + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + #endregion IDisposable + + #region Native Interop + + private static ConcurrentDictionary _sockets = new(); + + [DllImport("__Internal")] + private static extern IntPtr WebSocket_Create(string url, string subProtocols, WebSocket_OnOpenDelegate onOpen, WebSocket_OnMessageDelegate onMessage, WebSocket_OnErrorDelegate onError, WebSocket_OnCloseDelegate onClose); + + private delegate void WebSocket_OnOpenDelegate(IntPtr websocketPtr); + + [MonoPInvokeCallback(typeof(WebSocket_OnOpenDelegate))] + private static void WebSocket_OnOpen(IntPtr websocketPtr) + { + if (_sockets.TryGetValue(websocketPtr, out var socket)) + { + socket._events.Enqueue(() => socket.OnOpen?.Invoke()); + } + else + { + Debug.LogError($"{nameof(WebSocket_OnOpen)}: Invalid websocket pointer! {websocketPtr.ToInt64()}"); + } + } + + private delegate void WebSocket_OnMessageDelegate(IntPtr websocketPtr, IntPtr dataPtr, int length, OpCode type); + + [MonoPInvokeCallback(typeof(WebSocket_OnMessageDelegate))] + private static void WebSocket_OnMessage(IntPtr websocketPtr, IntPtr dataPtr, int length, OpCode type) + { + if (_sockets.TryGetValue(websocketPtr, out var socket)) + { + var buffer = new byte[length]; + Marshal.Copy(dataPtr, buffer, 0, length); + socket._events.Enqueue(() => socket.OnMessage?.Invoke(new DataFrame(type, buffer))); + } + else + { + Debug.LogError($"{nameof(WebSocket_OnMessage)}: Invalid websocket pointer! {websocketPtr.ToInt64()}"); + } + } + + private delegate void WebSocket_OnErrorDelegate(IntPtr websocketPtr, IntPtr messagePtr); + + [MonoPInvokeCallback(typeof(WebSocket_OnErrorDelegate))] + private static void WebSocket_OnError(IntPtr websocketPtr, IntPtr messagePtr) + { + if (_sockets.TryGetValue(websocketPtr, out var socket)) + { + var message = Marshal.PtrToStringUTF8(messagePtr); + socket._events.Enqueue(() => socket.OnError?.Invoke(new Exception(message))); + } + else + { + Debug.LogError($"{nameof(WebSocket_OnError)}: Invalid websocket pointer! {websocketPtr.ToInt64()}"); + } + } + + private delegate void WebSocket_OnCloseDelegate(IntPtr websocketPtr, CloseStatusCode code, IntPtr reasonPtr); + + [MonoPInvokeCallback(typeof(WebSocket_OnCloseDelegate))] + private static void WebSocket_OnClose(IntPtr websocketPtr, CloseStatusCode code, IntPtr reasonPtr) + { + if (_sockets.TryGetValue(websocketPtr, out var socket)) + { + var reason = Marshal.PtrToStringUTF8(reasonPtr); + socket._events.Enqueue(() => socket.OnClose?.Invoke(code, reason)); + } + else + { + Debug.LogError($"{nameof(WebSocket_OnClose)}: Invalid websocket pointer! {websocketPtr.ToInt64()}"); + } + } + + [DllImport("__Internal")] + private static extern int WebSocket_GetState(IntPtr websocketPtr); + + [DllImport("__Internal")] + private static extern void WebSocket_Connect(IntPtr websocketPtr); + + [DllImport("__Internal")] + private static extern void WebSocket_SendData(IntPtr websocketPtr, byte[] data, int length); + + [DllImport("__Internal")] + private static extern void WebSocket_SendString(IntPtr websocketPtr, string text); + + [DllImport("__Internal")] + private static extern void WebSocket_Close(IntPtr websocketPtr, CloseStatusCode code, string reason); + + [DllImport("__Internal")] + private static extern void WebSocket_Dispose(IntPtr websocketPtr); + + #endregion Native Interop + + /// + public event Action OnOpen; + + /// + public event Action OnMessage; + + /// + public event Action OnError; + + /// + public event Action OnClose; + + /// + public Uri Address { get; } + + /// + public IReadOnlyList SubProtocols { get; } + + /// + public State State => _socket != IntPtr.Zero + ? (State)WebSocket_GetState(_socket) + : State.Closed; + + private object _lock = new(); + private IntPtr _socket; + private SemaphoreSlim _semaphore = new(1, 1); + private CancellationTokenSource _lifetimeCts; + private readonly ConcurrentQueue _events = new(); + + /// + public async void Connect() + => await ConnectAsync(); + + /// + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + WebSocket_Connect(_socket); + await Task.CompletedTask; + } + + /// + public async Task SendAsync(string text, CancellationToken cancellationToken = default) + { + WebSocket_SendString(_socket, text); + await Task.CompletedTask; + } + + /// + public async Task SendAsync(ArraySegment data, CancellationToken cancellationToken = default) + { + WebSocket_SendData(_socket, data.Array, data.Count); + await Task.CompletedTask; + } + + /// + public async void Close() + => await CloseAsync(); + + /// + public async Task CloseAsync(CloseStatusCode code = CloseStatusCode.Normal, string reason = "", CancellationToken cancellationToken = default) + { + WebSocket_Close(_socket, code, reason); + await Task.CompletedTask; + } + } +} +#endif // PLATFORM_WEBGL && !UNITY_EDITOR diff --git a/Runtime/WebSocket_WebGL.cs.meta b/Runtime/WebSocket_WebGL.cs.meta new file mode 100644 index 0000000..c79aae4 --- /dev/null +++ b/Runtime/WebSocket_WebGL.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f165aea53e8eb7547bb3053a78570d70 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/WebsocketDemo.meta b/Samples~/WebsocketDemo.meta new file mode 100644 index 0000000..0f2cd7b --- /dev/null +++ b/Samples~/WebsocketDemo.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 687c34e3b9e88cc459456398119546e0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss b/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss new file mode 100644 index 0000000..79453c7 --- /dev/null +++ b/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss @@ -0,0 +1,2 @@ +@import url("unity-theme://default"); +VisualElement {} \ No newline at end of file diff --git a/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss.meta b/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss.meta new file mode 100644 index 0000000..20c8290 --- /dev/null +++ b/Samples~/WebsocketDemo/DefaultRuntimeTheme.tss.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de08e26de5e540e4a90b6be51bf83b27 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 diff --git a/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef b/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef new file mode 100644 index 0000000..836e370 --- /dev/null +++ b/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Utilities.WebSockets.Sample", + "rootNamespace": "Utilities.WebSockets.Sample", + "references": [ + "GUID:9fb4e1e06cb4c804ebfb0cff2b90e6d3" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef.meta b/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef.meta new file mode 100644 index 0000000..09c4327 --- /dev/null +++ b/Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3354db43e4e35024e83586c087159d2b +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/WebsocketDemo/WebSocketBindings.cs b/Samples~/WebsocketDemo/WebSocketBindings.cs new file mode 100644 index 0000000..a78d236 --- /dev/null +++ b/Samples~/WebsocketDemo/WebSocketBindings.cs @@ -0,0 +1,331 @@ +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Utilities.WebSockets.Sample +{ + [RequireComponent(typeof(UIDocument))] + public class WebSocketBindings : MonoBehaviour + { + [SerializeField] + private UIDocument uiDocument; + + [SerializeField] + private string address = "wss://echo.websocket.events"; + + private Label statusLabel; + private Label fpsLabel; + private TextField addressTextField; + private Button connectButton; + private Button disconnectButton; + private TextField sendMessageTextField; + private VisualElement sendMessageButtonGroup; + private Button sendTextButton; + private Button sendBytesButton; + private Button sendText1000Button; + private Button sendBytes1000Button; + private Toggle logMessagesToggle; + private Label sendCountLabel; + private Label receiveCountLabel; + private Button clearLogsButton; + private ListView messageListView; + + private int frame; + private int sendCount; + private int receiveCount; + + private float time; + private float fps; + + private WebSocket webSocket; + + private readonly List> logs = new(); + + private void OnValidate() + { + if (!uiDocument) + { + uiDocument = GetComponent(); + } + } + + private void Awake() + { + OnValidate(); + + var root = uiDocument.rootVisualElement; + + statusLabel = root.Q