From 7b0fc5c77e7423f273719b35067e0df3d5bcbed4 Mon Sep 17 00:00:00 2001 From: Stephen Hodgson Date: Sun, 12 May 2024 20:34:27 -0400 Subject: [PATCH] com.utilities.websockets 1.0.0-preview.1 (#1) --- Documentation~/README.md | 149 ++++++++ .../images/package-manager-scopes.png | Bin 0 -> 41173 bytes Editor.meta | 8 + Editor/AssemblyInfo.cs | 1 + Editor/AssemblyInfo.cs.meta | 11 + Editor/Utilities.Websockets.Editor.asmdef | 18 + .../Utilities.Websockets.Editor.asmdef.meta | 7 + LICENSE.md | 21 ++ LICENSE.md.meta | 7 + Runtime.meta | 8 + Runtime/AssemblyInfo.cs | 1 + Runtime/AssemblyInfo.cs.meta | 11 + Runtime/CloseStatusCode.cs | 80 +++++ Runtime/CloseStatusCode.cs.meta | 11 + Runtime/DataFrame.cs | 24 ++ Runtime/DataFrame.cs.meta | 11 + Runtime/IWebSocket.cs | 85 +++++ Runtime/IWebSocket.cs.meta | 11 + Runtime/OpCode.cs | 10 + Runtime/OpCode.cs.meta | 11 + Runtime/Plugins.meta | 8 + Runtime/Plugins/WebSocket.jslib | 247 +++++++++++++ Runtime/Plugins/WebSocket.jslib.meta | 32 ++ Runtime/State.cs | 32 ++ Runtime/State.cs.meta | 11 + Runtime/Utilities.Websockets.asmdef | 16 + Runtime/Utilities.Websockets.asmdef.meta | 7 + Runtime/WebSocket.cs | 286 +++++++++++++++ Runtime/WebSocket.cs.meta | 11 + Runtime/WebSocket_WebGL.cs | 264 ++++++++++++++ Runtime/WebSocket_WebGL.cs.meta | 11 + Samples~/WebsocketDemo.meta | 8 + .../WebsocketDemo/DefaultRuntimeTheme.tss | 2 + .../DefaultRuntimeTheme.tss.meta | 11 + .../Utilities.WebSockets.Sample.asmdef | 16 + .../Utilities.WebSockets.Sample.asmdef.meta | 7 + Samples~/WebsocketDemo/WebSocketBindings.cs | 331 ++++++++++++++++++ .../WebsocketDemo/WebSocketBindings.cs.meta | 11 + Samples~/WebsocketDemo/WebSocketDemo.unity | 277 +++++++++++++++ .../WebsocketDemo/WebSocketDemo.unity.meta | 7 + .../WebSocketExampleTemplate.uxml | 27 ++ .../WebSocketExampleTemplate.uxml.meta | 10 + .../WebsocketPanelSettings.asset | 38 ++ .../WebsocketPanelSettings.asset.meta | 8 + Tests.meta | 8 + Tests/TestFixture_01.cs | 58 +++ Tests/TestFixture_01.cs.meta | 11 + Tests/Utilities.Websockets.Tests.asmdef | 25 ++ Tests/Utilities.Websockets.Tests.asmdef.meta | 7 + package.json | 32 ++ package.json.meta | 7 + 51 files changed, 2311 insertions(+) create mode 100644 Documentation~/README.md create mode 100644 Documentation~/images/package-manager-scopes.png create mode 100644 Editor.meta create mode 100644 Editor/AssemblyInfo.cs create mode 100644 Editor/AssemblyInfo.cs.meta create mode 100644 Editor/Utilities.Websockets.Editor.asmdef create mode 100644 Editor/Utilities.Websockets.Editor.asmdef.meta create mode 100644 LICENSE.md create mode 100644 LICENSE.md.meta create mode 100644 Runtime.meta create mode 100644 Runtime/AssemblyInfo.cs create mode 100644 Runtime/AssemblyInfo.cs.meta create mode 100644 Runtime/CloseStatusCode.cs create mode 100644 Runtime/CloseStatusCode.cs.meta create mode 100644 Runtime/DataFrame.cs create mode 100644 Runtime/DataFrame.cs.meta create mode 100644 Runtime/IWebSocket.cs create mode 100644 Runtime/IWebSocket.cs.meta create mode 100644 Runtime/OpCode.cs create mode 100644 Runtime/OpCode.cs.meta create mode 100644 Runtime/Plugins.meta create mode 100644 Runtime/Plugins/WebSocket.jslib create mode 100644 Runtime/Plugins/WebSocket.jslib.meta create mode 100644 Runtime/State.cs create mode 100644 Runtime/State.cs.meta create mode 100644 Runtime/Utilities.Websockets.asmdef create mode 100644 Runtime/Utilities.Websockets.asmdef.meta create mode 100644 Runtime/WebSocket.cs create mode 100644 Runtime/WebSocket.cs.meta create mode 100644 Runtime/WebSocket_WebGL.cs create mode 100644 Runtime/WebSocket_WebGL.cs.meta create mode 100644 Samples~/WebsocketDemo.meta create mode 100644 Samples~/WebsocketDemo/DefaultRuntimeTheme.tss create mode 100644 Samples~/WebsocketDemo/DefaultRuntimeTheme.tss.meta create mode 100644 Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef create mode 100644 Samples~/WebsocketDemo/Utilities.WebSockets.Sample.asmdef.meta create mode 100644 Samples~/WebsocketDemo/WebSocketBindings.cs create mode 100644 Samples~/WebsocketDemo/WebSocketBindings.cs.meta create mode 100644 Samples~/WebsocketDemo/WebSocketDemo.unity create mode 100644 Samples~/WebsocketDemo/WebSocketDemo.unity.meta create mode 100644 Samples~/WebsocketDemo/WebSocketExampleTemplate.uxml create mode 100644 Samples~/WebsocketDemo/WebSocketExampleTemplate.uxml.meta create mode 100644 Samples~/WebsocketDemo/WebsocketPanelSettings.asset create mode 100644 Samples~/WebsocketDemo/WebsocketPanelSettings.asset.meta create mode 100644 Tests.meta create mode 100644 Tests/TestFixture_01.cs create mode 100644 Tests/TestFixture_01.cs.meta create mode 100644 Tests/Utilities.Websockets.Tests.asmdef create mode 100644 Tests/Utilities.Websockets.Tests.asmdef.meta create mode 100644 package.json create mode 100644 package.json.meta 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 0000000000000000000000000000000000000000..999ac4ae257694f0fc78d73c5696f022b8644f30 GIT binary patch literal 41173 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 HN@xNA + /// 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