Initial commit

This commit is contained in:
rudder
2026-08-12 14:03:44 +03:00
commit 9a7b4a57dc
304 changed files with 82311 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5479043f1ad90442fb5fb1464bed6a67
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: da41148b575cc423dbe6d514b5cb9a88
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,10 @@
namespace RudderSdk.Unity
{
public interface IUnityKeyValueStore
{
string GetString(string key);
void SetString(string key, string value);
void DeleteKey(string key);
void Save();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e6b223b25f3c9492b8f0d7d75528a4b2
@@ -0,0 +1,9 @@
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Unity
{
public interface IUnityLoggerSink
{
void Log(RudderLogLevel level, string message);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ec59e2140dfdd453ca968c21c67f6554
@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Unity
{
public interface IUnityRequestExecutor
{
Task<string> SendAsync(
string method,
string url,
string body,
IReadOnlyDictionary<string, string> headers,
CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e634b1eed56bc4eb4afce1f4558623a5
@@ -0,0 +1,17 @@
using System;
namespace RudderSdk.Unity
{
internal sealed class UnityRequestException : Exception
{
public int StatusCode { get; }
public string ResponseBody { get; }
public UnityRequestException(int statusCode, string responseBody, string message)
: base(message)
{
StatusCode = statusCode;
ResponseBody = responseBody;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f76d30c3864c428cbc217d51ea84c224
@@ -0,0 +1,85 @@
using System;
using System.Threading;
using UnityEngine;
namespace RudderSdk.Unity
{
public sealed class PlayerPrefsUnityKeyValueStore : IUnityKeyValueStore
{
private readonly SynchronizationContext _unityContext;
private readonly int _mainThreadId;
public PlayerPrefsUnityKeyValueStore()
{
_unityContext = SynchronizationContext.Current;
_mainThreadId = Thread.CurrentThread.ManagedThreadId;
}
public string GetString(string key)
{
return RunOnMainThread(() =>
{
var value = PlayerPrefs.GetString(key, string.Empty);
return string.IsNullOrEmpty(value) ? null : value;
});
}
public void SetString(string key, string value)
{
RunOnMainThread(() => PlayerPrefs.SetString(key, value));
}
public void DeleteKey(string key)
{
RunOnMainThread(() => PlayerPrefs.DeleteKey(key));
}
public void Save()
{
RunOnMainThread(PlayerPrefs.Save);
}
private void RunOnMainThread(Action action)
{
RunOnMainThread(() =>
{
action();
return true;
});
}
private T RunOnMainThread<T>(Func<T> action)
{
if (Thread.CurrentThread.ManagedThreadId == _mainThreadId || _unityContext == null)
return action();
T result = default;
Exception captured = null;
using (var completed = new ManualResetEventSlim(false))
{
_unityContext.Post(_ =>
{
try
{
result = action();
}
catch (Exception ex)
{
captured = ex;
}
finally
{
completed.Set();
}
}, null);
completed.Wait();
}
if (captured != null)
throw captured;
return result;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac69b887beaca42f285a77a665f01fe5
@@ -0,0 +1,30 @@
using RudderSdk.Core;
namespace RudderSdk.Unity
{
public static class RudderUnityClientFactory
{
public static RudderClient Create(RudderUnityClientOptions options)
{
var executor = options.RequestExecutor ?? new UnityWebRequestExecutor(options.TimeoutSeconds);
var clientOptions = new RudderClientOptions
{
BaseUrl = options.BaseUrl,
RealtimeUrl = options.RealtimeUrl,
ProjectKey = options.ProjectKey,
Logger = new UnityLoggerAdapter(options.LoggerSink ?? new UnityDebugLoggerSink()),
Transport = new UnityTransportAdapter(options.BaseUrl, executor),
UploadTransport = new UnityUploadTransport(options.TimeoutSeconds),
TokenStore = new UnityTokenStoreAdapter(options.KeyValueStore),
DeviceIdProvider = new UnityDeviceIdProvider(),
Clock = new UnityClock(),
PlanStateStore = new UnityPlanStateStore(options.KeyValueStore),
Scheduler = new UnityPlanScheduler(),
RealtimeTransportFactory = new UnityRealtimeTransportFactory()
};
return new RudderClient(clientOptions);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 68a7c35de38af47bead080881b0c6838
@@ -0,0 +1,13 @@
namespace RudderSdk.Unity
{
public class RudderUnityClientOptions
{
public string BaseUrl { get; set; }
public string RealtimeUrl { get; set; }
public string ProjectKey { get; set; }
public int TimeoutSeconds { get; set; } = 10;
public IUnityKeyValueStore KeyValueStore { get; set; }
public IUnityRequestExecutor RequestExecutor { get; set; }
public IUnityLoggerSink LoggerSink { get; set; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac789e74241fd4763bf08271729f15fa
@@ -0,0 +1,10 @@
using System;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Unity
{
internal class UnityClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aeb86d121ee7e4310be96a604cffa692
@@ -0,0 +1,24 @@
using RudderSdk.Core.Abstractions;
using UnityEngine;
namespace RudderSdk.Unity
{
public sealed class UnityDebugLoggerSink : IUnityLoggerSink
{
public void Log(RudderLogLevel level, string message)
{
switch (level)
{
case RudderLogLevel.Warning:
Debug.LogWarning("[Rudder] " + message);
break;
case RudderLogLevel.Error:
Debug.LogError("[Rudder] " + message);
break;
default:
Debug.Log("[Rudder] " + message);
break;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b30348f9b01a04e4c90e8ba3a06da65b
@@ -0,0 +1,28 @@
using System;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Unity
{
internal class UnityDeviceIdProvider : IDeviceIdProvider
{
public string DeviceId => SystemInfoHelper.GetDeviceId();
}
internal static class SystemInfoHelper
{
private static string _cachedDeviceId;
public static string GetDeviceId()
{
if (_cachedDeviceId != null) return _cachedDeviceId;
_cachedDeviceId = UnityEngine.PlayerPrefs.GetString("rudder_device_id", null);
if (string.IsNullOrEmpty(_cachedDeviceId))
{
_cachedDeviceId = Guid.NewGuid().ToString("N");
UnityEngine.PlayerPrefs.SetString("rudder_device_id", _cachedDeviceId);
UnityEngine.PlayerPrefs.Save();
}
return _cachedDeviceId;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b3555b2a84c5b4e0d80113f1af9c47b0
@@ -0,0 +1,13 @@
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Unity
{
internal class UnityLoggerAdapter : IRudderLogger
{
private readonly IUnityLoggerSink _sink;
public UnityLoggerAdapter(IUnityLoggerSink sink) => _sink = sink;
public void Log(RudderLogLevel level, string message) => _sink.Log(level, message);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e38911c13bcb54553a58bbda0fb20f1f
@@ -0,0 +1,15 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Unity
{
internal class UnityPlanScheduler : IPlanScheduler
{
public Task ScheduleAsync(TimeSpan delay, CancellationToken cancellationToken = default)
{
return Task.Delay(delay, cancellationToken);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f4f8e4e8237be445c96672d96294c6c7
@@ -0,0 +1,30 @@
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Unity
{
internal class UnityPlanStateStore : IPlanStateStore
{
private const string KEY = "liveops_plan_runner_state";
private readonly IUnityKeyValueStore _store;
public UnityPlanStateStore(IUnityKeyValueStore store)
{
_store = store;
}
public string State
{
get => _store.GetString(KEY);
set
{
if (string.IsNullOrEmpty(value))
_store.DeleteKey(KEY);
else
_store.SetString(KEY, value);
_store.Save();
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 118ea3286be8b4daab53eaf7885507a0
@@ -0,0 +1,51 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Unity
{
internal sealed class UnityRealtimeTransportFactory : IRealtimeTransportFactory
{
public IRealtimeTransport Create()
{
#if UNITY_WEBGL && !UNITY_EDITOR
return new UnityRealtimeTransport(new JsWebSocketAdapter());
#else
return new UnityRealtimeTransport(new NativeWebSocketAdapter());
#endif
}
}
internal sealed class UnityRealtimeTransport : IRealtimeTransport
{
private readonly IWebSocketAdapter _adapter;
public UnityRealtimeTransport(IWebSocketAdapter adapter)
{
_adapter = adapter;
_adapter.Closed += () => Closed?.Invoke();
_adapter.Received += data => Received?.Invoke(data);
_adapter.ReceivedError += ex => Error?.Invoke(ex);
}
public event Action Closed;
public event Action<ArraySegment<byte>> Received;
public event Action<Exception> Error;
public bool IsConnected => _adapter.IsConnected;
public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default)
=> _adapter.ConnectAsync(uri, (int)Math.Ceiling(timeout.TotalSeconds), cancellationToken);
public Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default)
=> _adapter.SendAsync(data, cancellationToken);
public Task CloseAsync(CancellationToken cancellationToken = default)
=> _adapter.CloseAsync();
public void Update(float deltaTime)
{
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be75d4878806d40648e49338f0ecd508
@@ -0,0 +1,28 @@
using RudderSdk.Core.Abstractions;
namespace RudderSdk.Unity
{
internal class UnityTokenStoreAdapter : ITokenStore
{
private readonly IUnityKeyValueStore _store;
public UnityTokenStoreAdapter(IUnityKeyValueStore store) => _store = store;
public string GetAccessToken() => _store.GetString("liveops_access_token");
public string GetRefreshToken() => _store.GetString("liveops_refresh_token");
public void SaveTokens(string accessToken, string refreshToken)
{
_store.SetString("liveops_access_token", accessToken);
_store.SetString("liveops_refresh_token", refreshToken);
_store.Save();
}
public void Clear()
{
_store.DeleteKey("liveops_access_token");
_store.DeleteKey("liveops_refresh_token");
_store.Save();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f25169510865d4391b518bb7e8b7a36f
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using RudderSdk.Core;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
namespace RudderSdk.Unity
{
internal class UnityTransportAdapter : IRudderTransport
{
private readonly string _baseUrl;
private readonly IUnityRequestExecutor _executor;
public UnityTransportAdapter(string baseUrl, IUnityRequestExecutor executor)
{
_baseUrl = baseUrl.TrimEnd('/');
_executor = executor;
}
public async Task<TResponse> SendAsync<TRequest, TResponse>(
string method,
string path,
TRequest request,
string accessToken,
CancellationToken cancellationToken = default)
{
var headers = new Dictionary<string, string>
{
["Content-Type"] = "application/json"
};
if (!string.IsNullOrEmpty(accessToken))
headers["Authorization"] = "Bearer " + accessToken;
var body = request != null ? JsonConvert.SerializeObject(request) : null;
string responseJson;
try
{
responseJson = await _executor.SendAsync(
method, _baseUrl + path, body, headers, cancellationToken).ConfigureAwait(false);
}
catch (UnityRequestException exception)
{
throw MapError(exception);
}
if (string.IsNullOrEmpty(responseJson))
return default;
return JsonConvert.DeserializeObject<TResponse>(responseJson);
}
private static RudderApiException MapError(UnityRequestException exception)
{
string code = null;
string message = null;
string requestId = null;
if (!string.IsNullOrEmpty(exception.ResponseBody))
{
try
{
var error = JsonConvert.DeserializeObject<ErrorResponse>(exception.ResponseBody);
code = error?.Code;
message = error?.Error;
requestId = error?.RequestId;
}
catch (JsonException)
{
}
}
if (string.IsNullOrEmpty(message))
message = exception.Message;
switch (exception.StatusCode)
{
case 401:
return new RudderAuthException(exception.StatusCode, code, message, requestId);
case 404:
return new RudderNotFoundException(exception.StatusCode, code, message, requestId);
case 429:
return new RudderRateLimitException(exception.StatusCode, code, message, requestId);
default:
return new RudderApiException(exception.StatusCode, code, message, requestId);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 05e990dc7adb8430780c5d4652655725
@@ -0,0 +1,57 @@
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core;
using RudderSdk.Core.Abstractions;
using UnityEngine.Networking;
namespace RudderSdk.Unity
{
internal sealed class UnityUploadTransport : IUploadTransport
{
private const int DefaultTimeoutSeconds = 10;
private readonly int _timeoutSeconds;
public UnityUploadTransport(int timeoutSeconds = DefaultTimeoutSeconds)
{
_timeoutSeconds = timeoutSeconds > 0 ? timeoutSeconds : DefaultTimeoutSeconds;
}
public async Task PutAsync(string url, byte[] data, string contentType, CancellationToken cancellationToken = default)
{
using (var request = UnityWebRequest.Put(url, data ?? System.Array.Empty<byte>()))
{
if (!string.IsNullOrEmpty(contentType))
request.SetRequestHeader("Content-Type", contentType);
request.timeout = _timeoutSeconds;
var operation = request.SendWebRequest();
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
operation.completed += _ => completion.TrySetResult(true);
using (cancellationToken.Register(() =>
{
request.Abort();
completion.TrySetCanceled(cancellationToken);
}))
{
await completion.Task.ConfigureAwait(false);
}
if (request.result == UnityWebRequest.Result.ConnectionError)
{
throw new RudderNetworkException(request.error);
}
if (request.result == UnityWebRequest.Result.ProtocolError)
{
throw new RudderApiException(
(int)request.responseCode,
string.Empty,
request.error + ": " + request.downloadHandler?.text);
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 415e95ebea4594057990efb6707f3ebc
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using RudderSdk.Core;
using UnityEngine.Networking;
namespace RudderSdk.Unity
{
public sealed class UnityWebRequestExecutor : IUnityRequestExecutor
{
private const int DefaultTimeoutSeconds = 10;
private readonly int _timeoutSeconds;
public UnityWebRequestExecutor(int timeoutSeconds = DefaultTimeoutSeconds)
{
_timeoutSeconds = timeoutSeconds > 0 ? timeoutSeconds : DefaultTimeoutSeconds;
}
public async Task<string> SendAsync(
string method,
string url,
string body,
IReadOnlyDictionary<string, string> headers,
CancellationToken cancellationToken = default)
{
using (var request = new UnityWebRequest(url, method))
{
if (!string.IsNullOrEmpty(body))
{
var payload = System.Text.Encoding.UTF8.GetBytes(body);
request.uploadHandler = new UploadHandlerRaw(payload);
}
request.downloadHandler = new DownloadHandlerBuffer();
request.timeout = _timeoutSeconds;
foreach (var header in headers)
{
request.SetRequestHeader(header.Key, header.Value);
}
var operation = request.SendWebRequest();
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
operation.completed += _ => completion.TrySetResult(true);
using (cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken)))
{
await completion.Task;
}
if (request.result == UnityWebRequest.Result.ConnectionError)
{
throw new RudderNetworkException(request.error);
}
if (request.result == UnityWebRequest.Result.ProtocolError)
{
var responseBody = request.downloadHandler.text;
throw new UnityRequestException(
(int)request.responseCode,
responseBody,
request.error + ": " + responseBody);
}
return request.downloadHandler?.text ?? string.Empty;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 13edd089b45e6405e97a5126b7772c1d
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3c34eb0ba13fe47eab84ba9a30d8bdb6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,22 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RudderSdk.Unity
{
/// <summary>
/// Interface for WebSocket transport adapters used by the realtime transport bridge.
/// Provides connect, send, close, and main-thread event dispatch for WebSocket connections.
/// </summary>
public interface IWebSocketAdapter
{
event Action Closed;
event Action<ArraySegment<byte>> Received;
event Action<Exception> ReceivedError;
bool IsConnected { get; }
bool IsConnecting { get; }
Task ConnectAsync(Uri uri, int timeoutSeconds, CancellationToken cancellationToken = default);
Task CloseAsync();
Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a7216258d1bc14b1699fce5966a73073
@@ -0,0 +1,312 @@
#if UNITY_WEBGL && !UNITY_EDITOR
using System.Runtime.InteropServices;
#endif
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace RudderSdk.Unity
{
/// <summary>
/// WebGL WebSocket adapter using JavaScript interop via DllImport("__Internal__").
/// Uses SendMessage callbacks from JS to route WebSocket events to Unity's main thread.
/// Only functional in WebGL builds; throws PlatformNotSupportedException elsewhere.
/// </summary>
public sealed class JsWebSocketAdapter : IWebSocketAdapter, IDisposable
{
#if UNITY_WEBGL && !UNITY_EDITOR
[DllImport("__Internal__")]
private static extern void RudderWebSocketCreate(string gameObjectName, string url);
[DllImport("__Internal__")]
private static extern void RudderWebSocketSend(string gameObjectName, byte[] data, int length);
[DllImport("__Internal__")]
private static extern void RudderWebSocketClose(string gameObjectName);
#endif
private CancellationTokenSource _cts;
private readonly ConcurrentQueue<Action> _eventQueue = new();
private JsWebSocketBridge _bridge;
private TaskCompletionSource<bool> _connectionTcs;
private volatile bool _isConnected;
private volatile bool _isConnecting;
private bool _disposed;
/// <summary>
/// Fired when the WebSocket connection is closed.
/// </summary>
public event Action Closed;
/// <summary>
/// Fired when binary data is received from the server.
/// </summary>
public event Action<ArraySegment<byte>> Received;
/// <summary>
/// Fired when an error occurs on the WebSocket.
/// </summary>
public event Action<Exception> ReceivedError;
/// <summary>
/// True when the WebSocket connection is open.
/// </summary>
public bool IsConnected => _isConnected;
/// <summary>
/// True while the WebSocket connection is being established.
/// </summary>
public bool IsConnecting => _isConnecting;
public JsWebSocketAdapter()
{
var go = new GameObject("JsWebSocketBridge");
UnityEngine.Object.DontDestroyOnLoad(go);
_bridge = go.AddComponent<JsWebSocketBridge>();
_bridge.Init(this);
}
/// <summary>
/// Connects to the specified WebSocket URI via the JavaScript WebSocket API.
/// </summary>
/// <param name="uri">The WebSocket server URI (ws:// or wss://).</param>
/// <param name="timeoutSeconds">Connection timeout in seconds.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <exception cref="ArgumentNullException">Thrown when uri is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when timeoutSeconds is not positive.</exception>
/// <exception cref="TimeoutException">Thrown when the connection attempt times out.</exception>
/// <exception cref="PlatformNotSupportedException">Thrown when not running in WebGL.</exception>
public Task ConnectAsync(Uri uri, int timeoutSeconds, CancellationToken cancellationToken = default)
{
if (uri == null)
throw new ArgumentNullException(nameof(uri));
if (timeoutSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(timeoutSeconds), "Timeout must be greater than zero.");
#if UNITY_WEBGL && !UNITY_EDITOR
_isConnecting = true;
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_connectionTcs = new TaskCompletionSource<bool>();
// Register cancellation (including timeout)
_cts.Token.Register(() =>
{
if (_connectionTcs?.TrySetException(
new TimeoutException($"WebSocket connection timed out after {timeoutSeconds} seconds.")) == true)
{
_isConnecting = false;
}
});
_cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
RudderWebSocketCreate(_bridge.gameObject.name, uri.ToString());
return _connectionTcs.Task;
#else
// Not available outside WebGL builds; editor test uses NativeWebSocketAdapter.
return Task.FromException(new PlatformNotSupportedException(
"JsWebSocketAdapter is only supported on WebGL. Use NativeWebSocketAdapter for other platforms."));
#endif
}
/// <summary>
/// Gracefully closes the WebSocket connection via the JavaScript WebSocket API.
/// </summary>
public Task CloseAsync()
{
#if UNITY_WEBGL && !UNITY_EDITOR
RudderWebSocketClose(_bridge.gameObject.name);
#endif
_cts?.Cancel();
_isConnected = false;
_isConnecting = false;
EnqueueEvent(() => Closed?.Invoke());
return Task.CompletedTask;
}
/// <summary>
/// Sends binary data over the WebSocket via the JavaScript WebSocket API.
/// </summary>
public Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default)
{
if (!_isConnected) return Task.CompletedTask;
#if UNITY_WEBGL && !UNITY_EDITOR
byte[] arr;
int offset;
int count;
if (data.Offset == 0 && data.Count == data.Array.Length)
{
arr = data.Array;
offset = 0;
count = data.Count;
}
else
{
// Copy to a contiguous buffer since DllImport needs a pinned array from offset 0
arr = new byte[data.Count];
System.Buffer.BlockCopy(data.Array, data.Offset, arr, 0, data.Count);
offset = 0;
count = data.Count;
}
RudderWebSocketSend(_bridge.gameObject.name, arr, count);
#endif
return Task.CompletedTask;
}
/// <summary>
/// Disposes the adapter, cleaning up the bridge GameObject.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
#if UNITY_WEBGL && !UNITY_EDITOR
RudderWebSocketClose(_bridge.gameObject.name);
#endif
_cts?.Cancel();
_cts?.Dispose();
if (_bridge != null && _bridge.gameObject != null)
{
UnityEngine.Object.Destroy(_bridge.gameObject);
}
}
/// <summary>
/// Called by JsWebSocketBridge when the JS WebSocket onopen fires.
/// </summary>
internal void HandleOpen()
{
_isConnected = true;
_isConnecting = false;
_connectionTcs?.TrySetResult(true);
}
/// <summary>
/// Called by JsWebSocketBridge when the JS WebSocket onclose fires.
/// </summary>
internal void HandleClose()
{
_isConnected = false;
_isConnecting = false;
_connectionTcs?.TrySetCanceled();
Closed?.Invoke();
}
/// Called by JsWebSocketBridge when the JS WebSocket onerror fires.
/// </summary>
internal void HandleError(string errorMessage)
{
_isConnecting = false;
_connectionTcs?.TrySetException(new Exception(errorMessage));
ReceivedError?.Invoke(new Exception(errorMessage));
}
/// Called by JsWebSocketBridge when binary message data is received.
/// </summary>
internal void HandleMessage(ArraySegment<byte> data)
{
Received?.Invoke(data);
}
internal void HandleMessageError(Exception ex)
{
ReceivedError?.Invoke(ex);
}
internal void EnqueueEvent(Action action)
{
_eventQueue.Enqueue(action);
}
internal void ProcessEvents()
{
while (_eventQueue.TryDequeue(out var action))
{
try
{
action?.Invoke();
}
catch (Exception ex)
{
Debug.LogException(ex);
}
}
}
}
/// <summary>
/// MonoBehaviour that receives SendMessage callbacks from the JavaScript WebSocket bridge
/// and dispatches events on the Unity main thread.
/// </summary>
internal class JsWebSocketBridge : MonoBehaviour
{
private JsWebSocketAdapter _adapter;
public void Init(JsWebSocketAdapter adapter)
{
_adapter = adapter;
}
/// <summary>
/// Called from JavaScript via SendMessage when the WebSocket connection opens.
/// </summary>
public void OnOpen(string _)
{
_adapter?.EnqueueEvent(() => _adapter?.HandleOpen());
}
/// <summary>
/// Called from JavaScript via SendMessage when binary data is received.
/// Data is base64-encoded by the JS bridge and decoded here.
/// </summary>
public void OnMessage(string base64Data)
{
if (string.IsNullOrEmpty(base64Data) || _adapter == null) return;
try
{
var data = Convert.FromBase64String(base64Data);
var segment = new ArraySegment<byte>(data);
_adapter.EnqueueEvent(() => _adapter.HandleMessage(segment));
}
catch (FormatException ex)
{
Debug.LogError($"JsWebSocketBridge: Failed to decode base64 message: {ex.Message}");
_adapter.EnqueueEvent(() => _adapter.HandleMessageError(ex));
}
}
/// <summary>
/// Called from JavaScript via SendMessage when the WebSocket connection closes.
/// </summary>
public void OnClose(string _)
{
_adapter?.EnqueueEvent(() => _adapter?.HandleClose());
}
/// <summary>
/// Called from JavaScript via SendMessage when a WebSocket error occurs.
/// </summary>
public void OnError(string error)
{
_adapter?.EnqueueEvent(() => _adapter?.HandleError(error ?? "Unknown WebSocket error"));
}
private void Update()
{
_adapter?.ProcessEvents();
}
private void OnDestroy()
{
_adapter?.Dispose();
_adapter = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e53d8ae2e7487470280f0da178c56cf8
@@ -0,0 +1,233 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace RudderSdk.Unity
{
/// <summary>
/// Native WebSocket adapter using System.Net.WebSockets.ClientWebSocket.
/// Dispatches received events to the Unity main thread via a MonoBehaviour Update loop.
/// </summary>
public sealed class NativeWebSocketAdapter : IWebSocketAdapter, IDisposable
{
private ClientWebSocket _ws;
private CancellationTokenSource _cts;
private readonly WebSocketDispatcher _dispatcher;
private readonly ConcurrentQueue<Action> _eventQueue = new();
private bool _disposed;
/// <summary>
/// Fired when the WebSocket connection is closed.
/// </summary>
public event Action Closed;
/// <summary>
/// Fired when binary data is received from the server.
/// </summary>
public event Action<ArraySegment<byte>> Received;
/// <summary>
/// Fired when an error occurs on the receive loop.
/// </summary>
public event Action<Exception> ReceivedError;
/// <summary>
/// True when the WebSocket is in the Open state.
/// </summary>
public bool IsConnected => _ws?.State == WebSocketState.Open;
/// <summary>
/// True when the WebSocket is in the Connecting state.
/// </summary>
public bool IsConnecting => _ws?.State == WebSocketState.Connecting;
public NativeWebSocketAdapter()
{
var go = new GameObject("WebSocketDispatcher");
UnityEngine.Object.DontDestroyOnLoad(go);
_dispatcher = go.AddComponent<WebSocketDispatcher>();
_dispatcher.Init(this);
}
/// <summary>
/// Connects to the specified WebSocket URI.
/// </summary>
/// <param name="uri">The WebSocket server URI (ws:// or wss://).</param>
/// <param name="timeoutSeconds">Connection timeout in seconds.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <exception cref="ArgumentNullException">Thrown when uri is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when timeoutSeconds is not positive.</exception>
/// <exception cref="TimeoutException">Thrown when the connection attempt times out.</exception>
public async Task ConnectAsync(Uri uri, int timeoutSeconds, CancellationToken cancellationToken = default)
{
if (uri == null)
throw new ArgumentNullException(nameof(uri));
if (timeoutSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(timeoutSeconds), "Timeout must be greater than zero.");
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_ws = new ClientWebSocket();
_ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, timeoutCts.Token);
try
{
await _ws.ConnectAsync(uri, linked.Token);
_ = ReceiveLoop();
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
throw new TimeoutException($"WebSocket connection timed out after {timeoutSeconds} seconds.");
}
}
/// <summary>
/// Gracefully closes the WebSocket connection.
/// </summary>
public async Task CloseAsync()
{
if (_ws?.State == WebSocketState.Open)
{
try
{
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closing", CancellationToken.None);
}
catch
{
// Best-effort close
}
}
_cts?.Cancel();
EnqueueEvent(() => Closed?.Invoke());
}
/// <summary>
/// Sends binary data over the WebSocket. WebSocket transport is always reliable,
/// so no reliability parameter is needed.
/// </summary>
public async Task SendAsync(ArraySegment<byte> data, CancellationToken cancellationToken = default)
{
if (_ws?.State != WebSocketState.Open)
return;
await _ws.SendAsync(data, WebSocketMessageType.Binary, true, cancellationToken);
}
/// <summary>
/// Disposes the adapter, cancelling any in-flight operations and cleaning up the dispatcher GameObject.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts?.Cancel();
_cts?.Dispose();
_ws?.Dispose();
if (_dispatcher != null && _dispatcher.gameObject != null)
{
UnityEngine.Object.Destroy(_dispatcher.gameObject);
}
}
private async Task ReceiveLoop()
{
var buffer = new byte[65536];
try
{
while (!_cts.IsCancellationRequested && _ws.State == WebSocketState.Open)
{
using (var ms = new MemoryStream())
{
WebSocketReceiveResult result;
do
{
result = await _ws.ReceiveAsync(new ArraySegment<byte>(buffer), _cts.Token);
ms.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
if (result.MessageType == WebSocketMessageType.Close)
{
try
{
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
}
catch
{
// Best-effort close
}
EnqueueEvent(() => Closed?.Invoke());
return;
}
var data = ms.ToArray();
EnqueueEvent(() => Received?.Invoke(new ArraySegment<byte>(data)));
}
}
}
catch (OperationCanceledException)
{
// Normal cancellation, no action needed
}
catch (Exception ex) when (!_disposed)
{
EnqueueEvent(() => ReceivedError?.Invoke(ex));
EnqueueEvent(() => Closed?.Invoke());
}
}
internal void EnqueueEvent(Action action)
{
_eventQueue.Enqueue(action);
}
internal void ProcessEvents()
{
while (_eventQueue.TryDequeue(out var action))
{
try
{
action?.Invoke();
}
catch (Exception ex)
{
Debug.LogException(ex);
}
}
}
}
/// <summary>
/// MonoBehaviour used to dispatch WebSocket events on the Unity main thread via Update.
/// Created and managed by NativeWebSocketAdapter.
/// </summary>
internal class WebSocketDispatcher : MonoBehaviour
{
private NativeWebSocketAdapter _adapter;
public void Init(NativeWebSocketAdapter adapter)
{
_adapter = adapter;
}
private void Update()
{
_adapter?.ProcessEvents();
}
private void OnDestroy()
{
_adapter?.Dispose();
_adapter = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 508609c897805437dab5527c21c9f914
@@ -0,0 +1,199 @@
using System;
using System.Threading.Tasks;
using UnityEngine;
using CoreClient = RudderSdk.Core.RudderClient;
namespace RudderSdk.Unity
{
public enum RudderState
{
NotInitialized,
Initializing,
Ready,
Failed
}
/// <summary>
/// Scene bootstrap MonoBehaviour and public entrypoint of the Rudder Unity SDK.
/// Add the component to a scene with a RudderConfiguration assigned, or call
/// Rudder.Initialize(configuration) and the hidden GameObject is created for you.
/// </summary>
[DefaultExecutionOrder(-1500), DisallowMultipleComponent]
public class Rudder : MonoBehaviour
{
private static Rudder _instance;
private static CoreClient _client;
private static TaskCompletionSource<CoreClient> _readyCompletion = NewCompletion();
[SerializeField] private RudderConfiguration _configuration;
/// Attach point for optional add-on packages so this assembly never has to
/// reference them. Sugar over Ready: subscribers that load late must check
/// State themselves.
public static event Action<CoreClient> Initialized;
public static RudderState State { get; private set; } = RudderState.NotInitialized;
public static Exception LastError { get; private set; }
public static CoreClient Client
{
get
{
if (State != RudderState.Ready || _client == null)
{
throw new InvalidOperationException(
"Rudder is not initialized. Add the Rudder component to the scene with a " +
"RudderConfiguration assigned, or call Rudder.Initialize(configuration).");
}
return _client;
}
}
/// Completes with the initialized client, or faults with the initialization error.
public static Task<CoreClient> Ready => _readyCompletion.Task;
public static void Initialize(RudderConfiguration configuration)
{
if (configuration == null)
throw new ArgumentNullException(nameof(configuration));
if (State == RudderState.Initializing || State == RudderState.Ready)
throw new InvalidOperationException("Rudder is already initialized.");
if (_instance == null)
{
var host = new GameObject(nameof(Rudder));
_instance = host.AddComponent<Rudder>();
}
_instance._configuration = configuration;
_ = _instance.InitializeAsync();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
_instance = null;
_client = null;
State = RudderState.NotInitialized;
LastError = null;
Initialized = null;
_readyCompletion = NewCompletion();
}
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
}
private void Start()
{
if (_instance == this && _configuration != null && State == RudderState.NotInitialized)
_ = InitializeAsync();
}
private async Task InitializeAsync()
{
State = RudderState.Initializing;
LastError = null;
try
{
var options = new RudderUnityClientOptions
{
BaseUrl = _configuration.BaseUrl,
RealtimeUrl = _configuration.RealtimeUrl,
ProjectKey = _configuration.ProjectKey,
TimeoutSeconds = _configuration.TimeoutSeconds,
KeyValueStore = new PlayerPrefsUnityKeyValueStore(),
LoggerSink = new UnityDebugLoggerSink()
};
_client = RudderUnityClientFactory.Create(options);
try
{
await _client.Scenario.RestoreAsync();
}
catch (Exception restoreException)
{
Debug.LogWarning("[Rudder] Failed to restore scenario state: " + restoreException.Message);
}
}
catch (Exception exception)
{
_client = null;
State = RudderState.Failed;
LastError = exception;
_readyCompletion.TrySetException(exception);
Debug.LogError(
"[Rudder] Failed to initialize the Rudder SDK. Check the RudderConfiguration asset " +
$"(ProjectKey and BaseUrl must be set). {exception.Message}");
return;
}
State = RudderState.Ready;
Debug.Log("[Rudder] Rudder SDK initialized.");
_readyCompletion.TrySetResult(_client);
Initialized?.Invoke(_client);
}
private void Update()
{
_client?.Update(Time.deltaTime);
}
private void OnApplicationQuit()
{
DisconnectRealtime();
}
private void OnDestroy()
{
if (_instance != this)
return;
DisconnectRealtime();
_instance = null;
_client = null;
State = RudderState.NotInitialized;
LastError = null;
_readyCompletion.TrySetException(new InvalidOperationException("Rudder was destroyed before initialization completed."));
_readyCompletion = NewCompletion();
}
private void DisconnectRealtime()
{
var client = _client;
if (client == null)
return;
try
{
_ = client.Realtime.DisconnectAsync().ContinueWith(
task => Debug.LogWarning(
"[Rudder] Realtime disconnect failed: " + task.Exception?.GetBaseException().Message),
TaskContinuationOptions.OnlyOnFaulted);
}
catch (Exception exception)
{
Debug.LogWarning("[Rudder] Realtime disconnect failed: " + exception.Message);
}
}
private static TaskCompletionSource<CoreClient> NewCompletion()
{
return new TaskCompletionSource<CoreClient>(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: efb778515f4ca44289f365faa2acecd7
@@ -0,0 +1,31 @@
using UnityEngine;
namespace RudderSdk.Unity
{
[CreateAssetMenu(menuName = "Rudder/Configuration", fileName = "RudderConfiguration")]
public class RudderConfiguration : ScriptableObject
{
[Tooltip("Project key issued in the Rudder admin panel. Required.")]
public string ProjectKey;
[Tooltip("API base URL, e.g. https://api.rudder.build.")]
public string BaseUrl = "http://localhost:8082";
[Tooltip("Realtime websocket URL.")]
public string RealtimeUrl = "ws://localhost:8090/api/realtime/ws";
[Tooltip("HTTP request timeout in seconds.")]
[Min(1)]
public int TimeoutSeconds = 10;
private void OnValidate()
{
if (string.IsNullOrEmpty(ProjectKey))
{
Debug.LogWarning(
"[Rudder] ProjectKey is empty on " + name + ". The SDK cannot authenticate without it.",
this);
}
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f384d06cd8614d2e986a2143feccfec6
timeCreated: 1759341357