2026-08-12 14:04:55 +03:00
|
|
|
using System;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using RudderSdk.Core.Abstractions;
|
|
|
|
|
using RudderSdk.Core.Models.Auth;
|
|
|
|
|
|
|
|
|
|
namespace RudderSdk.Core;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Entry point of the Rudder SDK. Composes all feature services around one
|
|
|
|
|
/// configuration (<see cref="RudderClientOptions"/>) and owns the session
|
|
|
|
|
/// lifecycle: every API call goes through here, a 401 triggers a single-flight
|
|
|
|
|
/// token refresh and one transparent retry.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class RudderClient
|
|
|
|
|
{
|
|
|
|
|
private readonly object _refreshGate = new();
|
|
|
|
|
private Task<bool>? _pendingRefresh;
|
|
|
|
|
|
|
|
|
|
/// <summary>Authentication: device login, token refresh, logout.</summary>
|
|
|
|
|
public AuthService Auth { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Player profile and wallets.</summary>
|
|
|
|
|
public PlayerService Player { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Remote configuration values.</summary>
|
|
|
|
|
public RemoteConfigService RemoteConfig { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Player key-value storage.</summary>
|
|
|
|
|
public StorageService Storage { get; }
|
|
|
|
|
|
2026-08-12 23:58:31 +03:00
|
|
|
/// <summary>Project key-value storage (global, shared across players).</summary>
|
|
|
|
|
public ProjectStorageService ProjectStorage { get; }
|
|
|
|
|
|
2026-08-12 14:04:55 +03:00
|
|
|
/// <summary>In-game stores and offer purchases.</summary>
|
|
|
|
|
public StoresService Stores { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Leaderboards.</summary>
|
|
|
|
|
public LeaderboardsService Leaderboards { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Player inventory.</summary>
|
|
|
|
|
public InventoryService Inventory { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Battle pass progress and rewards.</summary>
|
|
|
|
|
public BattlePassService BattlePass { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Global quests.</summary>
|
|
|
|
|
public QuestsService Quests { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Scenario runtime: triggers, node sessions, persistence.</summary>
|
|
|
|
|
public ScenarioService Scenario { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Realtime websocket channel.</summary>
|
|
|
|
|
public RealtimeService Realtime { get; }
|
|
|
|
|
|
|
|
|
|
internal RudderClientOptions Options { get; }
|
|
|
|
|
internal IRudderTransport Transport => Options.Transport!;
|
|
|
|
|
internal ITokenStore TokenStore => Options.TokenStore!;
|
|
|
|
|
internal IDeviceIdProvider DeviceIdProvider => Options.DeviceIdProvider!;
|
|
|
|
|
|
|
|
|
|
/// <summary>Project key from <see cref="RudderClientOptions.ProjectKey"/>.</summary>
|
|
|
|
|
public string ProjectKey => Options.ProjectKey!;
|
|
|
|
|
|
|
|
|
|
/// <summary>Realtime URL from <see cref="RudderClientOptions.RealtimeUrl"/>.</summary>
|
|
|
|
|
public string? RealtimeUrl => Options.RealtimeUrl;
|
|
|
|
|
|
|
|
|
|
/// <summary>Time source used by the scenario runtime.</summary>
|
|
|
|
|
public IClock Clock => Options.Clock ?? SystemClock.Instance;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates the client. <see cref="RudderClientOptions.BaseUrl"/> and
|
|
|
|
|
/// <see cref="RudderClientOptions.ProjectKey"/> are required; transport,
|
|
|
|
|
/// token store and device id provider fall back to defaults.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public RudderClient(RudderClientOptions options)
|
|
|
|
|
{
|
|
|
|
|
Options = options ?? throw new ArgumentNullException(nameof(options));
|
|
|
|
|
if (string.IsNullOrEmpty(Options.BaseUrl)) throw new ArgumentException("BaseUrl is required.", nameof(options));
|
|
|
|
|
if (string.IsNullOrEmpty(Options.ProjectKey)) throw new ArgumentException("ProjectKey is required.", nameof(options));
|
|
|
|
|
|
|
|
|
|
Options.Transport ??= new HttpClientTransport(Options.BaseUrl);
|
|
|
|
|
Options.TokenStore ??= new InMemoryTokenStore();
|
|
|
|
|
Options.DeviceIdProvider ??= new GuidDeviceIdProvider();
|
|
|
|
|
|
|
|
|
|
Auth = new AuthService(this);
|
|
|
|
|
Player = new PlayerService(this);
|
|
|
|
|
RemoteConfig = new RemoteConfigService(this);
|
|
|
|
|
Storage = new StorageService(this);
|
2026-08-12 23:58:31 +03:00
|
|
|
ProjectStorage = new ProjectStorageService(this);
|
2026-08-12 14:04:55 +03:00
|
|
|
Stores = new StoresService(this);
|
|
|
|
|
Leaderboards = new LeaderboardsService(this);
|
|
|
|
|
Inventory = new InventoryService(this);
|
|
|
|
|
Scenario = new ScenarioService(this);
|
|
|
|
|
BattlePass = new BattlePassService(this);
|
|
|
|
|
Quests = new QuestsService(this);
|
|
|
|
|
Realtime = new RealtimeService(this);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Pumps time-dependent services; call every frame.</summary>
|
|
|
|
|
public void Update(float deltaTime)
|
|
|
|
|
{
|
|
|
|
|
Scenario.Update(deltaTime);
|
|
|
|
|
Realtime.Update(deltaTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal Task<TResponse> SendAsync<TRequest, TResponse>(
|
|
|
|
|
string method,
|
|
|
|
|
string path,
|
|
|
|
|
TRequest? request,
|
|
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
return SendWithRetryAsync<TRequest, TResponse>(method, path, request, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal Task<TResponse> SendAsync<TResponse>(
|
|
|
|
|
string method,
|
|
|
|
|
string path,
|
|
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
return SendWithRetryAsync<object, TResponse>(method, path, null, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal Task SendAsync<TRequest>(
|
|
|
|
|
string method,
|
|
|
|
|
string path,
|
|
|
|
|
TRequest? request,
|
|
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
return SendWithRetryAsync<TRequest, EmptyResponse>(method, path, request, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal Task SendAsync(
|
|
|
|
|
string method,
|
|
|
|
|
string path,
|
|
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
return SendWithRetryAsync<object, EmptyResponse>(method, path, null, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<TResponse> SendWithRetryAsync<TRequest, TResponse>(
|
|
|
|
|
string method,
|
|
|
|
|
string path,
|
|
|
|
|
TRequest? request,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return await Transport
|
|
|
|
|
.SendAsync<TRequest, TResponse>(method, path, request, TokenStore.GetAccessToken(), cancellationToken)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch (RudderAuthException)
|
|
|
|
|
{
|
|
|
|
|
// Session rejected — refresh once (single-flight) and retry the call once.
|
|
|
|
|
if (!await RefreshTokensAsync().ConfigureAwait(false))
|
|
|
|
|
throw;
|
|
|
|
|
|
|
|
|
|
return await Transport
|
|
|
|
|
.SendAsync<TRequest, TResponse>(method, path, request, TokenStore.GetAccessToken(), cancellationToken)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Single-flight refresh: concurrent 401s share one refresh request.
|
|
|
|
|
/// Resolves to true when a new token pair was stored. On failure the tokens
|
|
|
|
|
/// are cleared and subscribers are notified via
|
|
|
|
|
/// <see cref="AuthService.AuthStateChanged"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal Task<bool> RefreshTokensAsync()
|
|
|
|
|
{
|
|
|
|
|
lock (_refreshGate)
|
|
|
|
|
{
|
|
|
|
|
if (_pendingRefresh != null)
|
|
|
|
|
return _pendingRefresh;
|
|
|
|
|
|
|
|
|
|
var task = DoRefreshTokensAsync();
|
|
|
|
|
_pendingRefresh = task;
|
|
|
|
|
task.ContinueWith(
|
|
|
|
|
_ =>
|
|
|
|
|
{
|
|
|
|
|
lock (_refreshGate)
|
|
|
|
|
{
|
|
|
|
|
if (ReferenceEquals(_pendingRefresh, task))
|
|
|
|
|
_pendingRefresh = null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
CancellationToken.None,
|
|
|
|
|
TaskContinuationOptions.ExecuteSynchronously,
|
|
|
|
|
TaskScheduler.Default);
|
|
|
|
|
return task;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<bool> DoRefreshTokensAsync()
|
|
|
|
|
{
|
|
|
|
|
var refreshToken = TokenStore.GetRefreshToken();
|
|
|
|
|
if (!string.IsNullOrEmpty(refreshToken))
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var response = await Transport
|
|
|
|
|
.SendAsync<RefreshAccessTokenRequest, RefreshAccessTokenResponse>(
|
|
|
|
|
"POST",
|
|
|
|
|
"/sdk/v1/authorization/refresh",
|
|
|
|
|
new RefreshAccessTokenRequest { RefreshToken = refreshToken },
|
|
|
|
|
null,
|
|
|
|
|
CancellationToken.None)
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (response != null
|
|
|
|
|
&& !string.IsNullOrEmpty(response.AccessToken)
|
|
|
|
|
&& !string.IsNullOrEmpty(response.RefreshToken))
|
|
|
|
|
{
|
|
|
|
|
TokenStore.SaveTokens(response.AccessToken, response.RefreshToken);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
// Transport-level failure during refresh — session is over.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TokenStore.Clear();
|
|
|
|
|
Auth.NotifySignedOut();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class SystemClock : IClock
|
|
|
|
|
{
|
|
|
|
|
public static readonly SystemClock Instance = new();
|
|
|
|
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal sealed class EmptyResponse
|
|
|
|
|
{
|
|
|
|
|
}
|