Initial commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0
|
||||
|
||||
Breaking changes, following the Rudder.Core 0.2.0 rework:
|
||||
|
||||
- `Rudder` moved into the `RudderSdk.Unity` namespace.
|
||||
- `Rudder` is now a thin facade: `Client`, `State` (`RudderState`),
|
||||
`LastError`, `Ready` and `Initialize(RudderConfiguration)`. The service
|
||||
shortcuts (`Rudder.Auth`, `Rudder.Stores`, ...) are gone — use
|
||||
`Rudder.Client.*`.
|
||||
- `Rudder.Initialize(configuration)` creates its own hidden GameObject when
|
||||
no scene component exists; statics reset on Enter Play Mode without domain
|
||||
reload, and realtime disconnects gracefully on quit.
|
||||
- `LiveOpsConfiguration` renamed to `RudderConfiguration`
|
||||
(`Create > Rudder > Configuration`), with tooltips, an empty-ProjectKey
|
||||
warning and a configurable `TimeoutSeconds` (default 10).
|
||||
- Core renames propagate: `client.Scenarios` → `client.Scenario`,
|
||||
`BattlepassService` → `BattlePassService`, `SendAsync` → `TriggerAsync`,
|
||||
`LoginViaDeviceAsync` → `LoginWithDeviceAsync(region, language, nickname?)`,
|
||||
`StoresService.BuyAsync` → `PurchaseAsync(slug, offerId, idempotencyKey?)`.
|
||||
- Scenario events reduced to the canonical set (`OnNotification`,
|
||||
`OnStoreOffer`, `OnLeaderboard`, `OnConfigChanged`, `OnWait`, `OnQuest`,
|
||||
`OnBattlePass`, `OnBattlePassLevel`, `OnScenarioCompleted`,
|
||||
`OnScenarioFailed`); legacy events and `*Session` renames applied.
|
||||
- Transport errors are now `RudderApiException` subclasses
|
||||
(`RudderAuthException`, `RudderNotFoundException`, `RudderRateLimitException`,
|
||||
`RudderNetworkException`); the bare `Exception` in the upload transport and
|
||||
the stringly `"network"` / `"http_error"` codes are gone.
|
||||
- `UnityWebRequestExecutor` / `UnityUploadTransport` wait via
|
||||
`UnityWebRequestAsyncOperation.completed` instead of busy-polling and apply
|
||||
a configurable `UnityWebRequest.timeout`.
|
||||
- `IRudderLogger` now reports a `RudderLogLevel`; `IUnityLoggerSink` mirrors
|
||||
it and `UnityDebugLoggerSink` maps levels to `Debug.Log*`.
|
||||
- Bundled plugins slimmed down: only `Rudder.Core.dll` ships. Newtonsoft.Json
|
||||
comes from `com.unity.nuget.newtonsoft-json`; `Tiny-JSON.dll` and the
|
||||
`System.*` facades were removed.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Initial UPM package: Rudder.Core wrapper with UnityWebRequest transport,
|
||||
PlayerPrefs storage, websocket realtime and the Cozy Collector demo.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3bb9335af31d469d8bf140d315ef053a
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,77 @@
|
||||
# Rudder SDK for Unity
|
||||
|
||||
LiveOps SDK for Unity: device auth, player profile, remote config, stores,
|
||||
inventory, leaderboards, battle pass, quests, storage, scenarios and realtime.
|
||||
|
||||
The package wraps `Rudder.Core.dll` (the .NET SDK) with Unity adapters:
|
||||
`UnityWebRequest` transport, `PlayerPrefs` token storage, websocket realtime.
|
||||
|
||||
## Installation
|
||||
|
||||
The package is published to the Rudder scoped registry. Add the registry to
|
||||
your project's `Packages/manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scopedRegistries": [
|
||||
{
|
||||
"name": "Rudder",
|
||||
"url": "https://hub.rudder.build/api/packages/rudder/npm/",
|
||||
"scopes": ["rudder"]
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"rudder.sdk": "0.2.0",
|
||||
"com.unity.nuget.newtonsoft-json": "3.2.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`com.unity.nuget.newtonsoft-json` is required — `Rudder.Core` serializes with
|
||||
Newtonsoft.Json and the package does not bundle the DLL.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Create a configuration asset via `Assets > Create > Rudder > Configuration`,
|
||||
set `ProjectKey`, `BaseUrl` and `RealtimeUrl`, then initialize once at
|
||||
startup:
|
||||
|
||||
```csharp
|
||||
using RudderSdk.Unity;
|
||||
|
||||
Rudder.Initialize(configuration); // or add the Rudder component to a scene
|
||||
var client = await Rudder.Ready; // faults if initialization failed
|
||||
|
||||
var session = await client.Auth.LoginWithDeviceAsync("global", "en", nickname: "Player");
|
||||
var configs = await client.RemoteConfig.LoadAsync();
|
||||
var stores = await client.Stores.ListAsync();
|
||||
```
|
||||
|
||||
All features hang off the client: `Auth`, `Player`, `BattlePass`, `Quests`,
|
||||
`Stores`, `Inventory`, `Leaderboards`, `RemoteConfig`, `Scenario`, `Storage`,
|
||||
`Ugc`, `Realtime`.
|
||||
|
||||
```csharp
|
||||
client.Scenario.OnStoreOffer += session => { /* show offer UI */ };
|
||||
await client.Scenario.TriggerAsync("player_login");
|
||||
|
||||
var purchase = await client.Stores.PurchaseAsync("cozy-camp-shop", "moonberry-boost");
|
||||
|
||||
var leaderboard = client.Leaderboards.FindBySlug("cozy-collector-score");
|
||||
await leaderboard.SubmitAsync(score);
|
||||
var top = await leaderboard.ListAsync(10);
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `Rudder.State` — `NotInitialized` / `Initializing` / `Ready` / `Failed`.
|
||||
- `Rudder.LastError` — the initialization error when `State` is `Failed`.
|
||||
- `Rudder.Client` — the initialized `RudderClient`; throws until `Ready`.
|
||||
- `Rudder.Ready` — task that completes with the client or the initialization
|
||||
error; `Rudder.Initialized` is the event-flavored sugar over it.
|
||||
- HTTP timeout defaults to 10 seconds, configurable per
|
||||
`RudderConfiguration.TimeoutSeconds`.
|
||||
|
||||
Errors surface as `RudderApiException` subclasses from `RudderSdk.Core`:
|
||||
`RudderAuthException` (401, session over), `RudderNotFoundException` (404),
|
||||
`RudderRateLimitException` (429) and `RudderNetworkException` (no response).
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9393d3d0da31417a9c6fa66e34d8b1cc
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3279529207dcc438294a9a209d1551f3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 003ad0009dc55064fb867a75cea6590b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 502ed8e3f646b4aa6a0b1d24af2458cd
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2dda93a7ed40941b3a831824b938fe46
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,66 @@
|
||||
// WebSocket bridge for the Rudder Unity SDK (JsWebSocketAdapter).
|
||||
// Implements the __Internal__ externals declared in Realtime/JsWebSocketAdapter.cs:
|
||||
// RudderWebSocketCreate(gameObjectName, url)
|
||||
// RudderWebSocketSend(gameObjectName, data, length)
|
||||
// RudderWebSocketClose(gameObjectName)
|
||||
// Events are routed back to the bridge GameObject via SendMessage:
|
||||
// OnOpen(string), OnMessage(string base64), OnClose(string), OnError(string)
|
||||
|
||||
var RudderWebSockets = {};
|
||||
|
||||
function RudderWebSocketBase64(bytes) {
|
||||
var chunks = [];
|
||||
var chunkSize = 0x8000;
|
||||
for (var i = 0; i < bytes.length; i += chunkSize) {
|
||||
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)));
|
||||
}
|
||||
return btoa(chunks.join(''));
|
||||
}
|
||||
|
||||
mergeInto(LibraryManager.library, {
|
||||
RudderWebSocketCreate: function (gameObjectName, url) {
|
||||
var name = UTF8ToString(gameObjectName);
|
||||
var socket = new WebSocket(UTF8ToString(url));
|
||||
socket.binaryType = 'arraybuffer';
|
||||
RudderWebSockets[name] = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
SendMessage(name, 'OnOpen', 'opened');
|
||||
};
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
var bytes;
|
||||
if (typeof event.data === 'string') {
|
||||
// Text frame: UTF-8 encode, then base64 (the C# side decodes base64 to bytes).
|
||||
bytes = new TextEncoder().encode(event.data);
|
||||
} else {
|
||||
bytes = new Uint8Array(event.data);
|
||||
}
|
||||
SendMessage(name, 'OnMessage', RudderWebSocketBase64(bytes));
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
SendMessage(name, 'OnError', 'WebSocket error');
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
delete RudderWebSockets[name];
|
||||
SendMessage(name, 'OnClose', 'closed');
|
||||
};
|
||||
},
|
||||
|
||||
RudderWebSocketSend: function (gameObjectName, data, length) {
|
||||
var socket = RudderWebSockets[UTF8ToString(gameObjectName)];
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(HEAPU8.subarray(data, data + length));
|
||||
},
|
||||
|
||||
RudderWebSocketClose: function (gameObjectName) {
|
||||
var name = UTF8ToString(gameObjectName);
|
||||
var socket = RudderWebSockets[name];
|
||||
if (socket) {
|
||||
delete RudderWebSockets[name];
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f90f5c02b3cd42e8a599649ae0a142d
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Rudder.Unity",
|
||||
"rootNamespace": "RudderSdk.Unity",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2848203c68a144310bfb0a6b7a3c8f2f
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2b099602291c4d959e66c25bf3c17f6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "rudder.sdk",
|
||||
"version": "0.2.0",
|
||||
"displayName": "Rudder SDK",
|
||||
"description": "LiveOps SDK for Unity — player auth, remote config, stores, inventory, leaderboards, scenarios, realtime.",
|
||||
"unity": "6000.0",
|
||||
"author": {
|
||||
"name": "Rudder",
|
||||
"url": "https://rudder.build"
|
||||
},
|
||||
"keywords": [
|
||||
"liveops",
|
||||
"rudder"
|
||||
],
|
||||
"documentationUrl": "https://app.rudder.build",
|
||||
"publishConfig": {
|
||||
"registry": "https://hub.rudder.build/api/packages/rudder/npm/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8a708ebab0242e39d995368d4978683
|
||||
PackageManifestImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user