using System; using System.Collections; using System.IO; using System.Reflection; using System.Text; using System.Threading; using BepInEx; using BepInEx.Logging; using HarmonyLib; using UnityEngine; using UnityEngine.Events; using UnityEngine.Networking; using UnityEngine.UI; namespace Bd2LoginUI; [BepInPlugin(Guid, Name, Version)] [BepInDependency("bd2.localidentity", BepInDependency.DependencyFlags.HardDependency)] public sealed class Plugin : BaseUnityPlugin { public const string Guid = "bd2.login.ui"; public const string Name = "BD2 Login UI"; public const string Version = Bd2Build.Versions.Plugin; private const string SymbolResource = "Bd2LoginUI.Assets.Discord-Symbol.png"; private const string WordmarkResource = "Bd2LoginUI.Assets.Discord-Wordmark.png"; private const string LocalAccessToken = "bd2-local-development-user"; private static readonly Color DiscordBlurple = new Color32(88, 101, 242, 255); private static ManualLogSource Log; private static Sprite DiscordSymbol; private static Sprite DiscordWordmark; private static MethodInfo SetIntroState; private static MethodInfo SendMaintenance; private static ServerAuthentication Authentication; private static Uri ServerRoot; private static string AuthenticationLoadingOrigin; private static bool ContinueMaintenance; private static bool LoginInProgress; private static MethodInfo OpenPCLoginPopup; private static MemoryAccessTokenStore AccessTokens; private static IRefreshCredentialStore RefreshCredentials; private static int SessionRecoveryInProgress; private static Plugin Owner; private static bool EstablishedGameSession; private static GameObject RecoveryOverlay; private static Text RecoveryText; private static MethodInfo EnterGame; private static string ServerInstanceID; private static int RecoveryRestartScheduled; private static int RecoveryEnterScheduled; private static int RecoveryGeneration; private static int RuntimeProbeFailures; private void Awake() { try { Log = Logger; Owner = this; AccessTokens = new MemoryAccessTokenStore(); RefreshCredentials = PlatformRefreshCredentialStore.Create(); StartCoroutine(RuntimeMonitor()); DiscordSymbol = LoadSprite(SymbolResource, "BD2 Discord Symbol"); DiscordWordmark = LoadSprite(WordmarkResource, "BD2 Discord Wordmark"); Type introUI = FindType("IntroUI"); MethodInfo awake = introUI?.GetMethod( "Awake", BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null); if (awake == null || awake.ReturnType != typeof(void)) { throw new MissingMethodException("IntroUI.Awake() was not found (client version mismatch)"); } SendMaintenance = introUI.GetMethod( "SendMaintenanceInfo", BindingFlags.Instance | BindingFlags.Public, null, new[] { typeof(bool) }, null); SetIntroState = FindSetIntroState(introUI); EnterGame = introUI.GetMethod( "ὤὣὦὤὪὩὯὢὢὠὭ", BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null) ?? introUI.GetMethod( "Enter", BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null); OpenPCLoginPopup = FindOpenPCLoginPopup(); MethodInfo accessTokenGetter = FindAccessTokenGetter(); MethodInfo clearPCLocalData = FindClearPCLocalData(); MethodInfo disposeWebRequest = typeof(UnityWebRequest).GetMethod( nameof(UnityWebRequest.Dispose), BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null); Type networkManager = FindType("BDNetwork.NetworkManager"); MethodInfo clientNetworkError = networkManager?.GetMethod( "ClientNetworkError", BindingFlags.Instance | BindingFlags.Public); MethodInfo exponentialBackOff = networkManager?.GetMethod( "ὧὥὡὠὮὦὯὥὭὣὩ", BindingFlags.Instance | BindingFlags.NonPublic) ?? networkManager?.GetMethod( "ExponetialBackOff", BindingFlags.Instance | BindingFlags.NonPublic); if (SendMaintenance == null || SetIntroState == null || OpenPCLoginPopup == null || accessTokenGetter == null || clearPCLocalData == null || disposeWebRequest == null || clientNetworkError == null || exponentialBackOff == null || EnterGame == null) { throw new MissingMethodException("IntroUI authentication transition methods were not found (client version mismatch)"); } Harmony harmony = new Harmony(Guid); harmony.Patch(awake, postfix: new HarmonyMethod(typeof(Plugin), nameof(IntroAwakePostfix))); HarmonyMethod maintenancePrefix = new HarmonyMethod(typeof(Plugin), nameof(SendMaintenancePrefix)); maintenancePrefix.after = new[] { "bd2.localidentity" }; harmony.Patch(SendMaintenance, prefix: maintenancePrefix); harmony.Patch( accessTokenGetter, prefix: new HarmonyMethod(typeof(Plugin), nameof(AccessTokenPrefix))); harmony.Patch( clearPCLocalData, postfix: new HarmonyMethod(typeof(Plugin), nameof(ClearPCLocalDataPostfix))); harmony.Patch( disposeWebRequest, prefix: new HarmonyMethod(typeof(Plugin), nameof(DisposeWebRequestPrefix))); harmony.Patch( clientNetworkError, prefix: new HarmonyMethod(typeof(Plugin), nameof(SuppressNetworkErrorDuringRecovery))); harmony.Patch( exponentialBackOff, prefix: new HarmonyMethod(typeof(Plugin), nameof(ExponentialBackoffPrefix))); harmony.Patch( SetIntroState, postfix: new HarmonyMethod(typeof(Plugin), nameof(SetIntroStatePostfix))); Logger.LogInfo("Server-authoritative Discord and Google login UI patch installed"); } catch (Exception ex) { Logger.LogError("Login UI patch failed: " + ex); } } private static void IntroAwakePostfix(object __instance) { ConfigureLoginPanel(__instance); } private static void DisposeWebRequestPrefix(UnityWebRequest __instance) { if (__instance == null || !__instance.isDone) { return; } InspectCompletedGameRequest(__instance); } private static void InspectCompletedGameRequest(UnityWebRequest request) { try { if (!IsCurrentGameRequest(request, out Uri requestUri)) { return; } if (requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) && request.responseCode >= 200 && request.responseCode < 300) { EstablishedGameSession = true; SetRecoveryMessage("正在同步玩家数据……\nSynchronizing player data…"); return; } bool accessExpired = requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) && request.responseCode == 401 && string.Equals(request.GetResponseHeader("X-BD2-Access-Expired"), "1", StringComparison.Ordinal); if (accessExpired) { AccessTokens.Clear(); BeginSessionRecovery("expired game access credential"); return; } bool sessionExpired = request.responseCode == 401 && string.Equals(request.GetResponseHeader("X-BD2-Session-Expired"), "1", StringComparison.Ordinal); bool serverRestarting = request.responseCode == 503 && string.Equals(request.GetResponseHeader("X-BD2-Reconnect"), "1", StringComparison.Ordinal); if (!sessionExpired && !serverRestarting) { return; } BeginSessionRecovery(serverRestarting ? "server restart" : "expired game session"); } catch (Exception ex) { Log?.LogError("Could not inspect the completed game request: " + ex); } } private static bool IsCurrentGameRequest(UnityWebRequest request, out Uri requestUri) { requestUri = null; if (ServerRoot == null || request == null || !Uri.TryCreate(request.url, UriKind.Absolute, out Uri parsed) || !SameOrigin(ServerRoot, parsed) || !parsed.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal)) { return false; } requestUri = parsed; return true; } private static void BeginSessionRecovery(string reason) { try { if (Owner == null || ServerRoot == null) { throw new InvalidOperationException("client recovery coordinator is unavailable"); } if (Interlocked.CompareExchange(ref SessionRecoveryInProgress, 1, 0) == 0) { Log?.LogWarning("Starting automatic game-session recovery: " + reason); ShowRecoveryOverlay("正在重新连接服务器……\nReconnecting to server…"); } else { Log?.LogWarning("Restarting automatic game-session recovery: " + reason); } ScheduleRecoveryRestart(); } catch { Interlocked.Exchange(ref SessionRecoveryInProgress, 0); throw; } } private static bool ExponentialBackoffPrefix(object __0) { if (ServerRoot == null || !IsConfiguredServerFailure(__0)) { return true; } if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { BeginSessionRecovery("transport failure during recovery"); return false; } if (!EstablishedGameSession) { return true; } BeginSessionRecovery("transport failure"); return false; } private static bool IsConfiguredServerFailure(object packetException) { if (packetException == null || ServerRoot == null) { return false; } const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; Type exceptionType = packetException.GetType(); string url = exceptionType.GetProperty("Url", flags)?.GetValue(packetException, null) as string; object packet = null; foreach (PropertyInfo property in exceptionType.GetProperties(flags)) { if (property.PropertyType.Name == "PacketData" && property.GetIndexParameters().Length == 0) { packet = property.GetValue(packetException, null); break; } } string requestServer = packet?.GetType().GetField("RequestServerURL", flags)?.GetValue(packet) as string; foreach (string candidate in new[] { requestServer, url }) { if (string.IsNullOrWhiteSpace(candidate)) { continue; } if (!Uri.TryCreate(candidate, UriKind.Absolute, out Uri parsed)) { parsed = new Uri(ServerRoot, candidate.TrimStart('/')); } if (SameOrigin(ServerRoot, parsed)) { return true; } } return false; } private static void ScheduleRecoveryRestart() { Interlocked.Increment(ref RecoveryGeneration); AuthenticationLoadingOrigin = null; LoginInProgress = false; ContinueMaintenance = false; EstablishedGameSession = false; RuntimeProbeFailures = 0; ServerInstanceID = null; Interlocked.Exchange(ref RecoveryEnterScheduled, 0); if (Interlocked.CompareExchange(ref RecoveryRestartScheduled, 1, 0) == 0) { Owner.StartCoroutine(StartLatestRecoveryGeneration()); } } private static IEnumerator StartLatestRecoveryGeneration() { int generation; do { generation = Volatile.Read(ref RecoveryGeneration); yield return new WaitForSecondsRealtime(0.25f); } while (generation != Volatile.Read(ref RecoveryGeneration)); Interlocked.Exchange(ref RecoveryRestartScheduled, 0); if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { Owner.StartCoroutine(WaitForServerAndRestart(generation)); } } private static IEnumerator WaitForServerAndRestart(int generation) { float delay = 0.5f; while (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation == Volatile.Read(ref RecoveryGeneration)) { SetRecoveryMessage("等待服务器启动……\nWaiting for server…"); using (UnityWebRequest request = UnityWebRequest.Get(new Uri(ServerRoot, "readyz"))) { request.timeout = 6; yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success && request.responseCode == 200) { break; } } yield return new WaitForSecondsRealtime(delay); delay = Math.Min(delay * 2f, 5f); } if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || generation != Volatile.Read(ref RecoveryGeneration)) { yield break; } SetRecoveryMessage("正在恢复登录会话……\nRestoring session…"); if (!RestartClientForRecovery()) { FinishRecovery(false, "client restart methods are unavailable", generation); } } private static bool RestartClientForRecovery() { object network = FindUnitySingleton("BDNetwork.NetworkManager"); object app = FindUnitySingleton("AppManager"); MethodInfo refresh = network?.GetType().GetMethod("Refresh", BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null); MethodInfo restart = app?.GetType().GetMethod("AppReStart", BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null); if (refresh == null || restart == null) { return false; } AuthenticationLoadingOrigin = null; LoginInProgress = false; ContinueMaintenance = false; Interlocked.Exchange(ref RecoveryEnterScheduled, 0); ServerInstanceID = null; refresh.Invoke(network, null); restart.Invoke(app, null); return true; } private static IEnumerator RuntimeMonitor() { WaitForSecondsRealtime interval = new WaitForSecondsRealtime(5f); while (true) { if (ServerRoot == null || !EstablishedGameSession || Volatile.Read(ref SessionRecoveryInProgress) != 0) { yield return interval; continue; } using (UnityWebRequest request = UnityWebRequest.Get(new Uri(ServerRoot, "client/runtime"))) { request.timeout = 6; yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { RuntimeProbeFailures++; if (RuntimeProbeFailures >= 3) { BeginSessionRecovery("runtime probe failed three consecutive times"); } } else { RuntimeProbeFailures = 0; RuntimeStatus status = null; try { status = JsonUtility.FromJson(request.downloadHandler.text); } catch (Exception ex) { Log?.LogWarning("Server runtime response was invalid: " + ex.Message); } if (status != null && !string.IsNullOrEmpty(status.instance_id)) { if (ServerInstanceID == null) { ServerInstanceID = status.instance_id; } else if (ServerInstanceID != status.instance_id || status.status == "draining") { ServerInstanceID = status.instance_id; BeginSessionRecovery(status.status == "draining" ? "server draining" : "server instance changed"); } } } } yield return interval; } } private static bool SuppressNetworkErrorDuringRecovery() { return Volatile.Read(ref SessionRecoveryInProgress) == 0; } private static void SetIntroStatePostfix(object __instance, object __0) { if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || __0 == null || Convert.ToInt32(__0) != 9 || Owner == null) { return; } if (Interlocked.CompareExchange(ref RecoveryEnterScheduled, 1, 0) != 0) { return; } Owner.StartCoroutine(EnterAfterAuthoritativeLoad(__instance, Volatile.Read(ref RecoveryGeneration))); } private static IEnumerator EnterAfterAuthoritativeLoad(object introUI, int generation) { SetRecoveryMessage("正在返回安全场景……\nReturning to a safe scene…"); yield return null; if (generation != Volatile.Read(ref RecoveryGeneration)) { yield break; } try { EnterGame.Invoke(introUI, null); } catch (Exception ex) { BeginSessionRecovery("could not enter safe scene: " + ex.Message); yield break; } float deadline = Time.realtimeSinceStartup + 30f; bool fieldLoaded = false; while (Time.realtimeSinceStartup < deadline && generation == Volatile.Read(ref RecoveryGeneration)) { object field = FindUnitySingleton("GameFieldManager"); PropertyInfo loaded = field?.GetType().GetProperty("IsLoadedField", BindingFlags.Instance | BindingFlags.Public); if (loaded != null && loaded.GetValue(field) is bool ready && ready) { fieldLoaded = true; break; } if (IsPackCollectionActive()) { fieldLoaded = true; break; } yield return new WaitForSecondsRealtime(0.25f); } if (!fieldLoaded) { if (generation == Volatile.Read(ref RecoveryGeneration)) { BeginSessionRecovery("safe scene load timed out"); } yield break; } FinishRecovery(true, null, generation); } private static bool IsPackCollectionActive() { Type uiManager = FindType("ὩὭὨὪὨὨὮὣὪὣὥ") ?? FindType("UIManager"); MethodInfo getUI = uiManager?.GetMethod( "GetUI", BindingFlags.Static | BindingFlags.Public, null, new[] { typeof(string) }, null); Component collection = getUI?.Invoke(null, new object[] { "PackCollectionUI" }) as Component; return collection != null && collection.gameObject != null && collection.gameObject.activeInHierarchy; } private static void ShowRecoveryOverlay(string message) { if (RecoveryOverlay == null) { RecoveryOverlay = new GameObject( "BD2 Recovery Overlay", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster), typeof(Image)); UnityEngine.Object.DontDestroyOnLoad(RecoveryOverlay); Canvas canvas = RecoveryOverlay.GetComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = short.MaxValue; Image background = RecoveryOverlay.GetComponent(); background.color = new Color(0.025f, 0.035f, 0.055f, 0.94f); RectTransform root = RecoveryOverlay.GetComponent(); root.anchorMin = Vector2.zero; root.anchorMax = Vector2.one; root.offsetMin = root.offsetMax = Vector2.zero; GameObject label = new GameObject("Status", typeof(RectTransform), typeof(Text)); label.transform.SetParent(RecoveryOverlay.transform, false); RecoveryText = label.GetComponent(); RecoveryText.font = Resources.GetBuiltinResource("Arial.ttf"); RecoveryText.fontSize = 28; RecoveryText.alignment = TextAnchor.MiddleCenter; RecoveryText.color = Color.white; RectTransform rect = label.GetComponent(); rect.anchorMin = new Vector2(0.15f, 0.35f); rect.anchorMax = new Vector2(0.85f, 0.65f); rect.offsetMin = rect.offsetMax = Vector2.zero; } RecoveryOverlay.SetActive(true); SetRecoveryMessage(message); } private static void SetRecoveryMessage(string message) { if (RecoveryText != null) { RecoveryText.text = message; } } private static void FinishRecovery(bool success, string error, int expectedGeneration = 0) { if (expectedGeneration != 0 && expectedGeneration != Volatile.Read(ref RecoveryGeneration)) { return; } if (!success) { Log?.LogError("Automatic session recovery failed: " + error); } if (RecoveryOverlay != null) { UnityEngine.Object.Destroy(RecoveryOverlay); RecoveryOverlay = null; RecoveryText = null; } Interlocked.Increment(ref RecoveryGeneration); Interlocked.Exchange(ref SessionRecoveryInProgress, 0); Interlocked.Exchange(ref RecoveryRestartScheduled, 0); Interlocked.Exchange(ref RecoveryEnterScheduled, 0); Log?.LogInfo(success ? "Automatic session recovery completed" : "Automatic session recovery stopped"); } private static bool AccessTokenPrefix(ref string __result) { if (Authentication != null && Authentication.mode == "oauth") { __result = AccessTokens.Get(); return false; } __result = LocalAccessToken; return false; } private static void ClearPCLocalDataPostfix() { AccessTokens.Clear(); EstablishedGameSession = false; RuntimeProbeFailures = 0; ServerInstanceID = null; PlayerPrefs.DeleteKey("AccessToken"); DeleteCurrentRefresh(); PlayerPrefs.Save(); } private static bool SendMaintenancePrefix(object __instance, bool __0) { if (!__0 || ContinueMaintenance) { return true; } try { if (!(__instance is Component)) { throw new InvalidOperationException("IntroUI is not a Unity component"); } Uri maintenance = CurrentMaintenanceUri(); Uri currentRoot = new Uri(maintenance, "/"); if (ServerRoot == null || !SameOrigin(ServerRoot, currentRoot)) { AccessTokens.Clear(); Authentication = null; LoginInProgress = false; EstablishedGameSession = false; RuntimeProbeFailures = 0; ServerInstanceID = null; ServerRoot = currentRoot; PlayerPrefs.DeleteKey("AccessToken"); PlayerPrefs.Save(); } if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && Authentication != null && Authentication.mode == "oauth" && AccessTokens.IsUsable(NormalizedServerOrigin())) { ContinueWithMaintenance(__instance, true); return false; } if (Authentication != null) { ApplyAuthenticationPolicy(__instance); return false; } string origin = NormalizedServerOrigin(); if (AuthenticationLoadingOrigin == origin) { return false; } AuthenticationLoadingOrigin = origin; StartIntroCoroutine(__instance, LoadAuthenticationPolicy(__instance, currentRoot, origin)); return false; } catch (Exception ex) { Log?.LogError("Could not request the server authentication policy: " + ex); return false; } } private static IEnumerator LoadAuthenticationPolicy(object introUI, Uri expectedRoot, string expectedOrigin) { Uri endpoint = new Uri(expectedRoot, "auth/config"); using (UnityWebRequest request = UnityWebRequest.Get(endpoint)) { request.timeout = 10; yield return request.SendWebRequest(); if (AuthenticationLoadingOrigin == expectedOrigin) { AuthenticationLoadingOrigin = null; } if (ServerRoot == null || !SameOrigin(ServerRoot, expectedRoot)) { yield break; } if (request.result != UnityWebRequest.Result.Success) { Log?.LogError("Authentication policy request failed: " + request.error); if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { BeginSessionRecovery("authentication policy request failed"); } yield break; } try { ServerAuthentication policy = JsonUtility.FromJson(request.downloadHandler.text); ValidateAuthentication(policy); Authentication = policy; Log?.LogInfo("Server authentication mode: " + policy.mode); ApplyAuthenticationPolicy(introUI); } catch (Exception ex) { Log?.LogError("Server returned an invalid authentication policy: " + ex.Message); if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { BeginSessionRecovery("authentication policy response was invalid"); } } } } private static void ApplyAuthenticationPolicy(object introUI) { if (Authentication.mode == "local") { try { AccessTokens.Clear(); PlayerPrefs.DeleteKey("AccessToken"); PlayerPrefs.Save(); ContinueMaintenance = true; SendMaintenance.Invoke(introUI, new object[] { true }); } finally { ContinueMaintenance = false; } return; } ValidateOAuthTransport(ServerRoot); // Earlier development builds stored both local identifiers and OAuth // access credentials in this key. OAuth credentials now live only in // process memory, so remove any legacy plaintext before proceeding. PlayerPrefs.DeleteKey("AccessToken"); if (!RefreshCredentials.IsSupported) { PlayerPrefs.SetInt("IsAutoLogin", 0); PlayerPrefs.SetInt("StandaloneAutoLogin", 0); Log?.LogWarning("Secure refresh credential storage is unavailable; automatic login is disabled on this platform"); } PlayerPrefs.Save(); if (LoginInProgress) { return; } if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { if (AccessTokens.IsUsable(NormalizedServerOrigin())) { ContinueWithMaintenance(introUI, true); return; } if (RefreshCredentials.IsSupported) { LoginInProgress = true; StartIntroCoroutine(introUI, RefreshSession(introUI)); return; } FinishRecovery(false, "no usable credential is available"); ShowLoginPanel(introUI); return; } if (PlayerPrefs.GetInt("IsAutoLogin", 0) != 0 && PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0 && CanAttemptAutomaticLogin()) { LoginInProgress = true; StartIntroCoroutine(introUI, RefreshSession(introUI)); } else { ShowLoginPanel(introUI); } } private static void ShowLoginPanel(object introUI) { AccessTokens.Clear(); LoginInProgress = false; EstablishedGameSession = false; ConfigureLoginPanel(introUI); Type stateType = SetIntroState.GetParameters()[0].ParameterType; SetIntroState.Invoke(introUI, new[] { Enum.ToObject(stateType, 1) }); Log?.LogInfo("Waiting for server-authorized third-party authentication"); } private static void ConfigureLoginPanel(object introUI) { if (Authentication == null || Authentication.mode != "oauth") { return; } try { Component component = introUI as Component; Transform panel = component == null ? null : FindDescendant(component.transform, "SignInWithAccount"); if (panel == null) { throw new MissingMemberException("IntroUI/SignInWithAccount was not found"); } Button google = FindButton(panel, "Button - Google"); Button discord = FindButton(panel, "Button - Facebook"); if (discord == null) { discord = FindButton(panel, "Button - Discord"); } if (google == null || discord == null) { throw new MissingMemberException("Google or Facebook/Discord login button was not found"); } Image box = FindImage(discord.transform, "Image - Box"); Image logo = FindImage(discord.transform, "Image - Logo"); Image title = FindImage(discord.transform, "Image - Title"); if (box == null || logo == null || title == null) { throw new MissingMemberException("Discord button images were not found"); } SetActive(panel, "Button - Apple", false); SetActive(panel, "Button - Email", false); SetActive(panel, "Button - Mail", false); SetActive(panel, "Button - Guest", false); google.gameObject.SetActive(ProviderEnabled("google")); discord.gameObject.SetActive(ProviderEnabled("discord")); int providerCount = (google.gameObject.activeSelf ? 1 : 0) + (discord.gameObject.activeSelf ? 1 : 0); discord.transform.SetSiblingIndex(0); google.transform.SetSiblingIndex(1); discord.gameObject.name = "Button - Discord"; ReplaceClick(google, introUI, "google"); ReplaceClick(discord, introUI, "discord"); ApplyDiscordBrand(box, logo, title); ConfigureProviderGrid(panel, providerCount); Canvas.ForceUpdateCanvases(); if (panel is RectTransform panelRect) { LayoutRebuilder.ForceRebuildLayoutImmediate(panelRect); } } catch (Exception ex) { Log?.LogError("Could not configure login panel: " + ex); } } private static void ConfigureProviderGrid(Transform panel, int providerCount) { GridLayoutGroup grid = panel.GetComponentInChildren(true); if (grid == null) { throw new MissingMemberException("Login provider grid was not found"); } int columns = Math.Max(1, providerCount); grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount; grid.constraintCount = columns; if (grid.transform is RectTransform gridRect) { float width = grid.padding.horizontal + grid.cellSize.x * columns + grid.spacing.x * Math.Max(0, columns - 1); UpdateBetterGridProfiles(grid, columns); UpdateBetterLocatorProfiles(grid, width); gridRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width); LayoutRebuilder.ForceRebuildLayoutImmediate(gridRect); } } private static void UpdateBetterGridProfiles(GridLayoutGroup grid, int columns) { Type type = grid.GetType(); if (type.FullName != "TheraBytes.BetterUi.BetterGridLayoutGroup") { return; } const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; UpdateBetterGridSettings(type.GetField("settingsFallback", flags)?.GetValue(grid), columns); object collection = type.GetField("customSettings", flags)?.GetValue(grid); IEnumerable items = collection?.GetType().GetProperty("Items", flags)?.GetValue(collection, null) as IEnumerable; if (items == null) { return; } foreach (object settings in items) { UpdateBetterGridSettings(settings, columns); } } private static void UpdateBetterGridSettings(object settings, int columns) { if (settings == null) { return; } Type type = settings.GetType(); FieldInfo constraint = type.GetField("Constraint", BindingFlags.Instance | BindingFlags.Public); FieldInfo count = type.GetField("ConstraintCount", BindingFlags.Instance | BindingFlags.Public); if (constraint != null) { constraint.SetValue(settings, Enum.ToObject(constraint.FieldType, (int)GridLayoutGroup.Constraint.FixedColumnCount)); } count?.SetValue(settings, columns); } private static void UpdateBetterLocatorProfiles(GridLayoutGroup grid, float width) { const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; foreach (Component component in grid.GetComponents()) { Type type = component?.GetType(); if (type?.FullName != "TheraBytes.BetterUi.BetterLocator") { continue; } UpdateBetterRectTransformData(type.GetField("transformFallback", flags)?.GetValue(component), width); object collection = type.GetField("transformConfigs", flags)?.GetValue(component); IEnumerable items = collection?.GetType().GetProperty("Items", flags)?.GetValue(collection, null) as IEnumerable; if (items == null) { continue; } foreach (object data in items) { UpdateBetterRectTransformData(data, width); } } } private static void UpdateBetterRectTransformData(object data, float width) { FieldInfo sizeField = data?.GetType().GetField("SizeDelta", BindingFlags.Instance | BindingFlags.Public); if (sizeField == null || sizeField.FieldType != typeof(Vector2)) { return; } Vector2 size = (Vector2)sizeField.GetValue(data); size.x = width; sizeField.SetValue(data, size); } private static void ReplaceClick(Button button, object introUI, string provider) { // Assigning a fresh event removes both serialized persistent calls and // the listeners that IntroUI.Awake adds at runtime. button.onClick = new Button.ButtonClickedEvent(); button.onClick.AddListener(new UnityAction(delegate { OpenLogin(introUI, provider); })); button.interactable = true; } private static void OpenLogin(object introUI, string provider) { try { if (LoginInProgress) { return; } PropertyInfo canInteraction = introUI?.GetType().GetProperty( "CanInteraction", BindingFlags.Instance | BindingFlags.Public); if (canInteraction != null && canInteraction.PropertyType == typeof(bool) && !(bool)canInteraction.GetValue(introUI, null)) { return; } if (!ProviderEnabled(provider)) { throw new InvalidOperationException("Provider is not enabled by this server"); } LoginInProgress = true; StartIntroCoroutine(introUI, DeviceLogin(introUI, provider)); } catch (Exception ex) { Log?.LogError("Could not start " + provider + " authentication: " + ex.Message); } } private static IEnumerator DeviceLogin(object introUI, string provider) { string endpoint = new Uri(ServerRoot, "auth/device").AbsoluteUri; byte[] body = Encoding.UTF8.GetBytes(JsonUtility.ToJson(new DeviceRequest { provider = provider })); using (UnityWebRequest request = JsonPost(endpoint, body)) { yield return request.SendWebRequest(); Array.Clear(body, 0, body.Length); if (request.result != UnityWebRequest.Result.Success) { LoginInProgress = false; Log?.LogError("Could not create login transaction: " + request.error); yield break; } DeviceStart start; try { start = JsonUtility.FromJson(request.downloadHandler.text); if (start == null || string.IsNullOrEmpty(start.transaction_id) || string.IsNullOrEmpty(start.device_secret) || string.IsNullOrEmpty(start.start_url)) { throw new InvalidDataException("incomplete transaction response"); } ValidateBrowserURL(start.start_url); } catch (Exception ex) { LoginInProgress = false; Log?.LogError("Invalid login transaction: " + ex.Message); yield break; } Application.OpenURL(start.start_url); yield return PollDevice(introUI, start); } } private static IEnumerator PollDevice(object introUI, DeviceStart start) { int delay = Math.Max(1, start.poll_interval); float deadline = Time.realtimeSinceStartup + Math.Max(30, start.expires_in); string endpoint = new Uri(ServerRoot, "auth/device/" + Uri.EscapeDataString(start.transaction_id) + "/poll").AbsoluteUri; while (Time.realtimeSinceStartup < deadline) { yield return new WaitForSecondsRealtime(delay); using (UnityWebRequest request = JsonPost(endpoint, Array.Empty())) { request.SetRequestHeader("Authorization", "Device " + start.device_secret); yield return request.SendWebRequest(); if (request.responseCode == 202) { continue; } if (request.result != UnityWebRequest.Result.Success) { LoginInProgress = false; Log?.LogError("Login transaction failed: HTTP " + request.responseCode); yield break; } TokenResult result; try { result = JsonUtility.FromJson(request.downloadHandler.text); } catch (Exception ex) { LoginInProgress = false; Log?.LogError("Login transaction returned invalid credentials: " + ex.Message); yield break; } if (!ValidTokenResult(result) || !ProviderEnabled(result.provider)) { LoginInProgress = false; Log?.LogError("Login transaction returned incomplete credentials"); yield break; } CompleteInteractiveLogin(introUI, result); yield break; } } LoginInProgress = false; Log?.LogError("Login transaction expired"); } private static void CompleteInteractiveLogin(object introUI, TokenResult result) { if (!RefreshCredentials.IsSupported) { AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in); result.access_token = null; result.refresh_token = null; PlayerPrefs.SetInt("IsAutoLogin", 0); PlayerPrefs.SetInt("StandaloneAutoLogin", 0); PlayerPrefs.DeleteKey("AccessToken"); PlayerPrefs.Save(); Log?.LogWarning("Login succeeded, but automatic login remains disabled because this platform has no supported secure credential store"); ContinueWithMaintenance(introUI, false); return; } Action confirmed = delegate { try { bool autoLogin = PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0; if (autoLogin) { StoreRefresh(result); } else { DeleteCurrentRefresh(); result.refresh_token = null; } AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in); result.access_token = null; PlayerPrefs.SetInt("IsAutoLogin", autoLogin ? 1 : 0); PlayerPrefs.DeleteKey("AccessToken"); PlayerPrefs.Save(); ContinueWithMaintenance(introUI, false); } catch (Exception ex) { result.access_token = null; result.refresh_token = null; Log?.LogError("Could not finish interactive login: " + ex.Message); AccessTokens.Clear(); LoginInProgress = false; ShowLoginPanel(introUI); } }; try { OpenPCLoginPopup.Invoke(null, new object[] { confirmed }); } catch { result.access_token = null; result.refresh_token = null; LoginInProgress = false; throw; } } private static IEnumerator RefreshSession(object introUI) { int generation = Volatile.Read(ref RecoveryGeneration); RefreshCredential saved = null; while (saved == null) { if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation != Volatile.Read(ref RecoveryGeneration)) { yield break; } InvalidDataException invalid = null; Exception transient = null; try { saved = PrepareRefreshAttempt(); } catch (InvalidDataException ex) { invalid = ex; } catch (FileNotFoundException ex) { invalid = new InvalidDataException("saved automatic-login credential was not found", ex); } catch (Exception ex) { transient = ex; } if (invalid != null) { Log?.LogError("Saved automatic login is invalid: " + invalid.Message); ClearSavedLogin(); if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { FinishRecovery(false, "saved automatic-login credential is invalid", generation); } ShowLoginPanel(introUI); yield break; } if (transient != null) { Log?.LogWarning("Secure automatic-login storage is temporarily unavailable: " + transient.Message); if (Volatile.Read(ref SessionRecoveryInProgress) == 0) { LoginInProgress = false; ShowLoginPanel(introUI); yield break; } yield return new WaitForSecondsRealtime(2f); } } string refreshToken = saved.pending_refresh_token; string attemptID = saved.pending_attempt_id; saved.refresh_token = null; saved.pending_refresh_token = null; saved.pending_attempt_id = null; float retryDelay = 1f; while (true) { byte[] body = BuildRefreshRequest(refreshToken, attemptID); using (UnityWebRequest request = JsonPost(new Uri(ServerRoot, "auth/session/refresh").AbsoluteUri, body)) { yield return request.SendWebRequest(); Array.Clear(body, 0, body.Length); if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation != Volatile.Read(ref RecoveryGeneration)) { yield break; } bool credentialRejected = (request.responseCode == 401 || request.responseCode == 409) && string.Equals(request.GetResponseHeader("X-BD2-Refresh-Invalid"), "1", StringComparison.Ordinal); if (credentialRejected) { refreshToken = null; attemptID = null; ClearSavedLogin(); if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { FinishRecovery(false, "saved automatic-login credential was rejected", generation); } ShowLoginPanel(introUI); yield break; } if (request.result != UnityWebRequest.Result.Success) { Log?.LogWarning("Automatic login temporarily unavailable; the same refresh attempt will be retried: " + request.error); if (Volatile.Read(ref SessionRecoveryInProgress) == 0) { LoginInProgress = false; ShowLoginPanel(introUI); yield break; } } else { TokenResult result = null; try { result = JsonUtility.FromJson(request.downloadHandler.text); } catch (Exception ex) { Log?.LogWarning("Automatic login returned an unreadable response; the same refresh attempt will be retried: " + ex.Message); } if (ValidRefreshResult(result) && ProviderEnabled(result.provider)) { try { StoreRefresh(result); if (result.access_expires_in <= 30) { result.access_token = null; saved = PrepareRefreshAttempt(); refreshToken = saved.pending_refresh_token; attemptID = saved.pending_attempt_id; saved.refresh_token = null; saved.pending_refresh_token = null; saved.pending_attempt_id = null; retryDelay = 1f; continue; } AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in); result.access_token = null; PlayerPrefs.DeleteKey("AccessToken"); PlayerPrefs.Save(); refreshToken = null; attemptID = null; } catch (Exception ex) { result.access_token = null; result.refresh_token = null; Log?.LogWarning("Could not persist the rotated automatic-login credential; the committed attempt will be retrieved again: " + ex.Message); if (Volatile.Read(ref SessionRecoveryInProgress) == 0) { LoginInProgress = false; ShowLoginPanel(introUI); yield break; } } if (AccessTokens.IsUsable(NormalizedServerOrigin())) { ContinueWithMaintenance(introUI, true); yield break; } } Log?.LogWarning("Automatic login returned incomplete credentials; the same refresh attempt will be retried"); if (Volatile.Read(ref SessionRecoveryInProgress) == 0) { LoginInProgress = false; ShowLoginPanel(introUI); yield break; } } } yield return new WaitForSecondsRealtime(retryDelay); retryDelay = Math.Min(retryDelay * 2f, 5f); } } private static void ContinueWithMaintenance(object introUI, bool automatic) { try { ContinueMaintenance = true; SendMaintenance.Invoke(introUI, new object[] { automatic }); } finally { ContinueMaintenance = false; LoginInProgress = false; } } private static UnityWebRequest JsonPost(string url, byte[] body) { UnityWebRequest request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST) { uploadHandler = new UploadHandlerRaw(body), downloadHandler = new DownloadHandlerBuffer(), timeout = 15 }; request.SetRequestHeader("Content-Type", "application/json"); return request; } private static void StartIntroCoroutine(object introUI, IEnumerator routine) { if (!(introUI is MonoBehaviour owner) || owner == null) { throw new InvalidOperationException("IntroUI coroutine owner is unavailable"); } owner.StartCoroutine(routine ?? throw new ArgumentNullException(nameof(routine))); } private static bool CanAttemptAutomaticLogin() { if (!RefreshCredentials.IsSupported) { return false; } try { return RefreshCredentials.Contains(NormalizedServerOrigin()); } catch (Exception ex) { Log?.LogWarning("Could not inspect the secure automatic-login credential: " + ex.Message); return false; } } private static void StoreRefresh(TokenResult result) { string origin = NormalizedServerOrigin(); RefreshCredential credential = new RefreshCredential { version = 2, origin = origin, provider = result.provider, refresh_token = result.refresh_token, expires_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + result.refresh_expires_in }; RefreshCredentials.Save(origin, credential); credential.refresh_token = null; result.refresh_token = null; } private static RefreshCredential LoadRefresh() { string origin = NormalizedServerOrigin(); RefreshCredential credential = RefreshCredentials.Load(origin); if (credential == null || credential.version < 1 || credential.version > 2 || credential.origin != origin || !ProviderEnabled(credential.provider) || string.IsNullOrEmpty(credential.refresh_token) || credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds()) { if (credential != null) { credential.refresh_token = null; } throw new InvalidDataException("saved automatic-login credential is invalid, expired, or belongs to another server"); } return credential; } private static RefreshCredential PrepareRefreshAttempt() { string origin = NormalizedServerOrigin(); RefreshCredential credential = LoadRefresh(); bool missingAttempt = string.IsNullOrEmpty(credential.pending_attempt_id) || string.IsNullOrEmpty(credential.pending_refresh_token); if (missingAttempt) { credential.version = 2; credential.pending_attempt_id = System.Guid.NewGuid().ToString("N"); credential.pending_refresh_token = credential.refresh_token; RefreshCredentials.Save(origin, credential); } return credential; } private static byte[] BuildRefreshRequest(string token, string attemptID) { if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(attemptID)) { throw new InvalidDataException("refresh token or attempt ID is empty"); } foreach (char item in token + attemptID) { bool safe = item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '-' || item == '_'; if (!safe) { throw new InvalidDataException("refresh token contains an unexpected character"); } } return Encoding.UTF8.GetBytes("{\"refresh_token\":\"" + token + "\",\"attempt_id\":\"" + attemptID + "\"}"); } private static void ClearSavedLogin() { AccessTokens.Clear(); PlayerPrefs.SetInt("IsAutoLogin", 0); PlayerPrefs.SetInt("StandaloneAutoLogin", 0); PlayerPrefs.DeleteKey("AccessToken"); DeleteCurrentRefresh(); PlayerPrefs.Save(); } private static void DeleteCurrentRefresh() { if (ServerRoot == null || RefreshCredentials == null || !RefreshCredentials.IsSupported) { return; } try { RefreshCredentials.Delete(NormalizedServerOrigin()); } catch (Exception ex) { Log?.LogWarning("Could not delete the secure automatic-login credential: " + ex.Message); } } private static bool ValidTokenResult(TokenResult result) { return result != null && !string.IsNullOrEmpty(result.provider) && !string.IsNullOrEmpty(result.access_token) && result.access_expires_in > 0 && !string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0; } private static bool ValidRefreshResult(TokenResult result) { return result != null && !string.IsNullOrEmpty(result.provider) && !string.IsNullOrEmpty(result.access_token) && result.access_expires_in >= 0 && !string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0; } private static string NormalizedServerOrigin() { return NormalizeOrigin(ServerRoot); } private static bool SameOrigin(Uri left, Uri right) { return string.Equals(NormalizeOrigin(left), NormalizeOrigin(right), StringComparison.Ordinal); } private static string NormalizeOrigin(Uri uri) { if (uri == null || !uri.IsAbsoluteUri || string.IsNullOrEmpty(uri.Host)) { throw new InvalidOperationException("authentication server origin is unavailable"); } string host = uri.IdnHost.ToLowerInvariant(); int port = uri.IsDefaultPort ? -1 : uri.Port; UriBuilder builder = new UriBuilder(uri.Scheme.ToLowerInvariant(), host, port); return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/'); } private static void ValidateOAuthTransport(Uri uri) { if (uri == null || !uri.IsAbsoluteUri || (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) { throw new InvalidOperationException("OAuth requires HTTPS except when connecting to a loopback server"); } } private static void ValidateBrowserURL(string raw) { if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri) || (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) || uri.UserInfo.Length != 0) { throw new InvalidDataException("server returned an unsafe browser login URL"); } } private static void ApplyDiscordBrand(Image box, Image logo, Image title) { box.color = DiscordBlurple; logo.sprite = DiscordSymbol; logo.color = Color.white; logo.preserveAspect = true; title.sprite = DiscordWordmark; title.color = Color.white; title.preserveAspect = true; DisableSpriteLocalizer(logo.gameObject); DisableSpriteLocalizer(title.gameObject); } private static void DisableSpriteLocalizer(GameObject target) { foreach (Behaviour behaviour in target.GetComponents()) { if (behaviour.GetType().Name == "SpriteLocalizer") { behaviour.enabled = false; } } } private static Sprite LoadSprite(string resourceName, string name) { using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName); if (stream == null) { throw new FileNotFoundException("Embedded login asset is missing", resourceName); } byte[] bytes = new byte[stream.Length]; int offset = 0; while (offset < bytes.Length) { int read = stream.Read(bytes, offset, bytes.Length - offset); if (read == 0) { throw new EndOfStreamException("Unexpected end of embedded login asset " + resourceName); } offset += read; } Texture2D texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, false) { name = name, filterMode = FilterMode.Bilinear, wrapMode = TextureWrapMode.Clamp }; if (!ImageConversion.LoadImage(texture, bytes, true)) { UnityEngine.Object.Destroy(texture); throw new InvalidDataException("Could not decode embedded login asset " + resourceName); } Sprite sprite = Sprite.Create( texture, new Rect(0f, 0f, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); sprite.name = name; return sprite; } private static Button FindButton(Transform root, string name) { Transform match = FindDescendant(root, name); return match == null ? null : match.GetComponent