From 9355623b449cfe900f3bc711ff44d127b1b20943 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Sun, 29 Mar 2026 22:15:51 -0500 Subject: [PATCH 1/7] Adding Health Check and Reconnect Functionally. --- .github/docker/headless/linux/Dockerfile | 1 + .github/docker/headless/windows/Dockerfile | 1 + .github/workflows/build-docker-server.yml | 7 + .../Headless/BasisHeadlessHealthCheck.cs | 241 +++++++++++ .../Headless/BasisHeadlessHealthCheck.cs.meta | 2 + .../Headless/BasisHeadlessManagement.cs | 381 ++++++++++++++++-- .../Headless/BasisHeadlessRuntimeStatus.cs | 259 ++++++++++++ .../BasisHeadlessRuntimeStatus.cs.meta | 2 + .../Networking/BasisNetworkConnection.cs | 19 + 9 files changed, 877 insertions(+), 36 deletions(-) create mode 100644 Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessHealthCheck.cs create mode 100644 Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessHealthCheck.cs.meta create mode 100644 Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessRuntimeStatus.cs create mode 100644 Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessRuntimeStatus.cs.meta diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile index 6df700d2e3..87da22a38a 100644 --- a/.github/docker/headless/linux/Dockerfile +++ b/.github/docker/headless/linux/Dockerfile @@ -11,6 +11,7 @@ RUN apt-get update && \ WORKDIR /app ARG ARTIFACT_DIR=HeadlessLinux64 COPY ./${ARTIFACT_DIR}/. . +EXPOSE 10666/tcp RUN set -eux; \ exe="$(find . -maxdepth 1 -type f \( -name 'HeadlessLinuxServer.x86_64' -o -name 'HeadlessLinuxServer.arm64' -o -name 'HeadlessLinuxServer' \) | head -n1)"; \ test -n "$exe"; \ diff --git a/.github/docker/headless/windows/Dockerfile b/.github/docker/headless/windows/Dockerfile index 259b63dfb9..b9f4b64a77 100644 --- a/.github/docker/headless/windows/Dockerfile +++ b/.github/docker/headless/windows/Dockerfile @@ -2,5 +2,6 @@ FROM mcr.microsoft.com/windows/servercore:ltsc2022 WORKDIR C:\app COPY ./HeadlessWindows64/. . +EXPOSE 10666 ENTRYPOINT ["C:\\app\\HeadlessWindowsServer.exe"] diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index a6dd4ab8be..c766d8d470 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -231,6 +231,13 @@ jobs: default_password server1.basisvr.org 4296 + false + 0.0.0.0 + 10666 + /health + true + 5 + 10 EOF - name: "Upload headless artifact" diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessHealthCheck.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessHealthCheck.cs new file mode 100644 index 0000000000..5a575967be --- /dev/null +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessHealthCheck.cs @@ -0,0 +1,241 @@ +using System; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +public sealed class BasisHeadlessHealthCheck : IDisposable +{ + private static readonly byte[] Empty = Array.Empty(); + + private readonly HttpListener httpListener = new HttpListener(); + private readonly CancellationTokenSource cts = new CancellationTokenSource(); + private readonly string pathNormalized; + private Task listenTask; + + public BasisHeadlessHealthCheck(string host, int port, string path) + { + pathNormalized = NormalizePath(path); + httpListener.Prefixes.Add($"http://{host}:{port}/"); + httpListener.Start(); + BasisHeadlessRuntimeStatus.SetHealthListenerRunning(true); + listenTask = ListenLoopAsync(cts.Token); + } + + public static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "/"; + } + + path = path.Trim(); + if (!path.StartsWith("/")) + { + path = "/" + path; + } + + if (path.Length > 1 && path.EndsWith("/")) + { + path = path.Substring(0, path.Length - 1); + } + + return path; + } + + private async Task ListenLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + HttpListenerContext context = null; + try + { + context = await httpListener.GetContextAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (HttpListenerException) + { + return; + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning("Headless health check loop error: " + ex); + continue; + } + + _ = Task.Run(() => HandleRequest(context), token); + } + } + + private void HandleRequest(HttpListenerContext context) + { + try + { + HttpListenerRequest req = context.Request; + HttpListenerResponse res = context.Response; + + res.Headers["Cache-Control"] = "no-store, max-age=0"; + res.Headers["X-Content-Type-Options"] = "nosniff"; + + if (!string.Equals(req.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)) + { + res.StatusCode = 405; + res.Close(Empty, false); + return; + } + + string reqPath = NormalizePath(req.Url.AbsolutePath); + if (!string.Equals(reqPath, pathNormalized, StringComparison.Ordinal)) + { + res.StatusCode = 404; + res.Close(Empty, false); + return; + } + + BasisHeadlessRuntimeStatus.Snapshot snapshot = BasisHeadlessRuntimeStatus.CreateSnapshot(); + bool healthy = snapshot.IsConnected && snapshot.State == BasisHeadlessConnectionState.Connected; + string json = BuildJson(snapshot, healthy); + byte[] payload = Encoding.UTF8.GetBytes(json); + + res.StatusCode = healthy ? 200 : 503; + res.ContentType = "application/json; charset=utf-8"; + res.ContentEncoding = Encoding.UTF8; + res.ContentLength64 = payload.Length; + res.OutputStream.Write(payload, 0, payload.Length); + res.OutputStream.Close(); + } + catch + { + try + { + context?.Response?.Abort(); + } + catch + { + } + } + } + + private static string BuildJson(BasisHeadlessRuntimeStatus.Snapshot snapshot, bool healthy) + { + StringBuilder builder = new StringBuilder(512); + builder.Append('{'); + AppendBool(builder, "listening", snapshot.IsHealthListenerRunning); + AppendBool(builder, "connected", snapshot.IsConnected); + AppendBool(builder, "healthy", healthy); + AppendString(builder, "state", snapshot.State.ToString()); + AppendString(builder, "serverIp", snapshot.ConfiguredServerIp); + AppendInt(builder, "serverPort", snapshot.ConfiguredServerPort); + AppendBool(builder, "retryEnabled", snapshot.RetryEnabled); + AppendInt(builder, "currentRetryAttempt", snapshot.CurrentRetryAttempt); + AppendInt(builder, "maxRetryAttempts", snapshot.MaxRetryAttempts); + AppendInt(builder, "retryDelaySeconds", snapshot.RetryDelaySeconds); + AppendInt(builder, "totalDisconnectCount", snapshot.TotalDisconnectCount); + AppendInt(builder, "totalReconnectSuccessCount", snapshot.TotalReconnectSuccessCount); + AppendString(builder, "lastDisconnectReason", snapshot.LastDisconnectReason); + AppendString(builder, "lastDisconnectSocketError", snapshot.LastDisconnectSocketError); + AppendNullableString(builder, "lastDisconnectMessage", snapshot.LastDisconnectMessage); + AppendString(builder, "healthPath", snapshot.HealthPath); + AppendString(builder, "currentTimeUtc", DateTimeOffset.UtcNow.ToString("O")); + AppendString(builder, "startTimeUtc", snapshot.StartTimeUtc.ToString("O")); + AppendNullableDate(builder, "lastConnectAttemptUtc", snapshot.LastConnectAttemptUtc); + AppendNullableDate(builder, "lastConnectedUtc", snapshot.LastConnectedUtc); + AppendNullableDate(builder, "lastDisconnectedUtc", snapshot.LastDisconnectedUtc); + AppendString(builder, "lastHealthStateChangeUtc", snapshot.LastHealthStateChangeUtc.ToString("O")); + if (builder[builder.Length - 1] == ',') + { + builder.Length--; + } + + builder.Append('}'); + return builder.ToString(); + } + + private static void AppendBool(StringBuilder builder, string name, bool value) + { + builder.Append('"').Append(name).Append("\":").Append(value ? "true" : "false").Append(','); + } + + private static void AppendInt(StringBuilder builder, string name, int value) + { + builder.Append('"').Append(name).Append("\":").Append(value).Append(','); + } + + private static void AppendString(StringBuilder builder, string name, string value) + { + builder.Append('"').Append(name).Append("\":\"").Append(Escape(value ?? string.Empty)).Append("\","); + } + + private static void AppendNullableString(StringBuilder builder, string name, string value) + { + builder.Append('"').Append(name).Append("\":"); + if (value == null) + { + builder.Append("null,"); + return; + } + + builder.Append('"').Append(Escape(value)).Append("\","); + } + + private static void AppendNullableDate(StringBuilder builder, string name, DateTimeOffset? value) + { + builder.Append('"').Append(name).Append("\":"); + if (value.HasValue) + { + builder.Append('"').Append(value.Value.ToString("O")).Append("\","); + } + else + { + builder.Append("null,"); + } + } + + private static string Escape(string value) + { + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } + + public void Dispose() + { + if (cts.IsCancellationRequested) + { + return; + } + + cts.Cancel(); + BasisHeadlessRuntimeStatus.SetHealthListenerRunning(false); + try + { + httpListener.Stop(); + } + catch + { + } + + try + { + httpListener.Close(); + } + catch + { + } + + try + { + listenTask?.Wait(250); + } + catch + { + } + + cts.Dispose(); + } +} diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessHealthCheck.cs.meta b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessHealthCheck.cs.meta new file mode 100644 index 0000000000..71a74bf471 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessHealthCheck.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 53a6cd28d2066a043b0f6ce6c0d281c7 \ No newline at end of file diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs index fccfb9e07b..956e187ee8 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs @@ -1,4 +1,5 @@ using Basis.BasisUI; +using Basis.Network.Core; using Basis.Scripts.BasisSdk.Players; using Basis.Scripts.Device_Management; using Basis.Scripts.Device_Management.Devices.Headless; @@ -7,6 +8,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using UnityEngine; @@ -16,6 +18,7 @@ /// Headless bootstrap/cleanup and auto-connect flow for server builds. /// Handles scene stripping (textures, probes, UI), config load, and network connect. /// +#if UNITY_SERVER public class BasisHeadlessManagement : BasisBaseTypeManagement { /// Injected/created headless eye input. @@ -30,6 +33,20 @@ public class BasisHeadlessManagement : BasisBaseTypeManagement /// Server port loaded from config or default. public static int Port = 4296; + public static bool HealthCheckEnabled = false; + public static string HealthCheckHost = "0.0.0.0"; + public static int HealthCheckPort = 10666; + public static string HealthPath = "/health"; + public static bool ReconnectEnabled = true; + public static int ReconnectDelaySeconds = 5; + public static int MaxReconnectAttempts = 10; + + private BasisHeadlessHealthCheck healthCheck; + private CancellationTokenSource reconnectCts; + private bool isShuttingDown; + private bool hasLoadedStartupContent; + private bool reconnectScheduled; + /// /// Scene change hook used in headless to aggressively strip visuals and free memory. /// @@ -54,7 +71,9 @@ private void RemoveAllMaterialTextures() foreach (Material mat in renderer.materials) { if (mat == null || processedMats.Contains(mat)) + { continue; + } ShaderUtilSafe.ClearAllKnownTextures(mat); processedMats.Add(mat); @@ -69,16 +88,12 @@ private void RemoveAllMaterialTextures() /// public static class ShaderUtilSafe { - // Commonly used texture property names across Standard/URP shaders private static readonly string[] commonTextureProps = { "_MainTex", "_BaseMap", "_BumpMap", "_EmissionMap", "_MetallicGlossMap", "_ParallaxMap", "_OcclusionMap", "_DetailMask", "_DetailAlbedoMap", "_DetailNormalMap" }; - /// - /// Sets all known texture properties to null on the material if present. - /// public static void ClearAllKnownTextures(Material material) { foreach (string prop in commonTextureProps) @@ -110,10 +125,10 @@ private void RemoveAllReflectionProbes() /// private void RemoveAllText() { - Canvas[] Canvases = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); - foreach (Canvas Canvas in Canvases) + Canvas[] canvases = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + foreach (Canvas canvas in canvases) { - Destroy(Canvas); + Destroy(canvas); } Debug.Log("All reflection probes removed from scene."); @@ -121,7 +136,7 @@ private void RemoveAllText() /// /// Loads config.xml from or creates it with defaults. - /// Updates , , and . + /// Updates headless runtime settings from env/config/defaults. /// public static void LoadOrCreateConfigXml() { @@ -129,42 +144,85 @@ public static void LoadOrCreateConfigXml() string defaultPassword = Password; string defaultIp = Ip; int defaultPort = Port; + bool defaultHealthCheckEnabled = HealthCheckEnabled; + string defaultHealthCheckHost = HealthCheckHost; + int defaultHealthCheckPort = HealthCheckPort; + string defaultHealthPath = HealthPath; + bool defaultReconnectEnabled = ReconnectEnabled; + int defaultReconnectDelaySeconds = ReconnectDelaySeconds; + int defaultMaxReconnectAttempts = MaxReconnectAttempts; + string envPassword = ReadEnvironmentString("Password"); string envIp = ReadEnvironmentString("Ip"); int? envPort = ReadEnvironmentInt("Port"); - - if (envPassword != null && envIp != null && envPort.HasValue) - { - Password = envPassword; - Ip = envIp; - Port = envPort.Value; - return; - } + bool? envHealthCheckEnabled = ReadEnvironmentBool("HealthCheckEnabled"); + string envHealthCheckHost = ReadEnvironmentString("HealthCheckHost"); + int? envHealthCheckPort = ReadEnvironmentInt("HealthCheckPort"); + string envHealthPath = ReadEnvironmentString("HealthPath"); + bool? envReconnectEnabled = ReadEnvironmentBool("ReconnectEnabled"); + int? envReconnectDelaySeconds = ReadEnvironmentInt("ReconnectDelaySeconds"); + int? envMaxReconnectAttempts = ReadEnvironmentInt("MaxReconnectAttempts"); XElement root = null; if (File.Exists(filePath)) { - var doc = XDocument.Load(filePath); + XDocument doc = XDocument.Load(filePath); root = doc.Element("Configuration"); } else { - TryCreateDefaultConfigXml(filePath, defaultPassword, defaultIp, defaultPort); + TryCreateDefaultConfigXml( + filePath, + defaultPassword, + defaultIp, + defaultPort, + defaultHealthCheckEnabled, + defaultHealthCheckHost, + defaultHealthCheckPort, + defaultHealthPath, + defaultReconnectEnabled, + defaultReconnectDelaySeconds, + defaultMaxReconnectAttempts); } Password = envPassword ?? root?.Element("Password")?.Value ?? defaultPassword; Ip = envIp ?? root?.Element("Ip")?.Value ?? defaultIp; Port = envPort ?? ReadXmlInt(root?.Element("Port")?.Value, defaultPort); + HealthCheckEnabled = envHealthCheckEnabled ?? ReadXmlBool(root?.Element("HealthCheckEnabled")?.Value, defaultHealthCheckEnabled); + HealthCheckHost = envHealthCheckHost ?? root?.Element("HealthCheckHost")?.Value ?? defaultHealthCheckHost; + HealthCheckPort = envHealthCheckPort ?? ReadXmlInt(root?.Element("HealthCheckPort")?.Value, defaultHealthCheckPort); + HealthPath = BasisHeadlessHealthCheck.NormalizePath(envHealthPath ?? root?.Element("HealthPath")?.Value ?? defaultHealthPath); + ReconnectEnabled = envReconnectEnabled ?? ReadXmlBool(root?.Element("ReconnectEnabled")?.Value, defaultReconnectEnabled); + ReconnectDelaySeconds = Mathf.Max(1, envReconnectDelaySeconds ?? ReadXmlInt(root?.Element("ReconnectDelaySeconds")?.Value, defaultReconnectDelaySeconds)); + MaxReconnectAttempts = Mathf.Max(0, envMaxReconnectAttempts ?? ReadXmlInt(root?.Element("MaxReconnectAttempts")?.Value, defaultMaxReconnectAttempts)); } - private static void TryCreateDefaultConfigXml(string filePath, string password, string ip, int port) + private static void TryCreateDefaultConfigXml( + string filePath, + string password, + string ip, + int port, + bool healthCheckEnabled, + string healthCheckHost, + int healthCheckPort, + string healthPath, + bool reconnectEnabled, + int reconnectDelaySeconds, + int maxReconnectAttempts) { try { - var defaultConfig = new XElement("Configuration", + XElement defaultConfig = new XElement("Configuration", new XElement("Password", password), new XElement("Ip", ip), - new XElement("Port", port) + new XElement("Port", port), + new XElement("HealthCheckEnabled", healthCheckEnabled), + new XElement("HealthCheckHost", healthCheckHost), + new XElement("HealthCheckPort", healthCheckPort), + new XElement("HealthPath", BasisHeadlessHealthCheck.NormalizePath(healthPath)), + new XElement("ReconnectEnabled", reconnectEnabled), + new XElement("ReconnectDelaySeconds", reconnectDelaySeconds), + new XElement("MaxReconnectAttempts", maxReconnectAttempts) ); new XDocument(defaultConfig).Save(filePath); } @@ -185,6 +243,23 @@ private static string ReadEnvironmentString(string envName) return envValue; } + private static bool? ReadEnvironmentBool(string envName) + { + string envValue = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrWhiteSpace(envValue)) + { + return null; + } + + if (bool.TryParse(envValue, out bool parsed)) + { + return parsed; + } + + Debug.LogWarning($"Invalid headless environment variable '{envName}' value '{envValue}'. Falling back to config.xml/defaults."); + return null; + } + private static int? ReadEnvironmentInt(string envName) { string envValue = Environment.GetEnvironmentVariable(envName); @@ -207,20 +282,27 @@ private static int ReadXmlInt(string value, int fallback) return int.TryParse(value, out int parsed) ? parsed : fallback; } + private static bool ReadXmlBool(string value, bool fallback) + { + return bool.TryParse(value, out bool parsed) ? parsed : fallback; + } + /// - /// Reads config, loads default scene (if configured), and connects to network as client. + /// Reads config, loads default scene once, and connects to network as client. /// public async void ConnectToNetwork() { LoadOrCreateConfigXml(); - await CreateAssetBundle(); - BasisNetworkManagement.Instance.Ip = Ip; - BasisNetworkManagement.Instance.Password = Password; - BasisNetworkManagement.Instance.IsHostMode = false; - BasisNetworkManagement.Instance.Port = (ushort)Port; - BasisNetworkManagement.Instance.Connect(); - BasisDebug.Log("connecting to default"); - BasisMainMenu.Close(); + ApplyRuntimeConfiguration(); + StartHealthEndpoint(); + + if (!hasLoadedStartupContent) + { + hasLoadedStartupContent = true; + await CreateAssetBundle(); + } + + AttemptConnect(); } /// @@ -247,6 +329,15 @@ public async Task CreateAssetBundle() public override void StartSDK() { #if UNITY_SERVER + isShuttingDown = false; + hasLoadedStartupContent = false; + reconnectScheduled = false; + reconnectCts = new CancellationTokenSource(); + BasisHeadlessRuntimeStatus.Reset(); + BasisNetworkConnection.HeadlessReconnectSuppressed = false; + BasisNetworkConnection.OnDisconnectedAfterReboot -= OnDisconnectedAfterReboot; + BasisNetworkConnection.OnDisconnectedAfterReboot += OnDisconnectedAfterReboot; + if (BasisLocalPlayer.PlayerReady && BasisLocalPlayer.Instance != null) { EnsureHeadlessInput(); @@ -258,11 +349,9 @@ public override void StartSDK() } BasisDebug.Log(nameof(StartSDK), BasisDebug.LogTag.Device); - // Name the player BasisLocalPlayer.Instance.DisplayName = GenerateRandomPlayerName(); BasisLocalPlayer.Instance.SetSafeDisplayname(); - // Connect (immediately or when network manager appears) if (BasisNetworkManagement.Instance != null) { ConnectToNetwork(); @@ -272,7 +361,6 @@ public override void StartSDK() BasisNetworkManagement.OnEnableInstanceCreate += ConnectToNetwork; } - // Strip visuals on scene switches SceneManager.activeSceneChanged += OnSceneLoadeded; #endif BasisDebug.Log(nameof(StartSDK), BasisDebug.LogTag.Device); @@ -281,6 +369,14 @@ public override void StartSDK() private void OnDestroy() { #if UNITY_SERVER + isShuttingDown = true; + BasisNetworkConnection.HeadlessReconnectSuppressed = true; + BasisNetworkConnection.OnDisconnectedAfterReboot -= OnDisconnectedAfterReboot; + BasisNetworkManagement.OnEnableInstanceCreate -= ConnectToNetwork; + SceneManager.activeSceneChanged -= OnSceneLoadeded; + CancelReconnectLoop(); + StopHealthEndpoint(); + BasisHeadlessRuntimeStatus.MarkStopping(); BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; #endif } @@ -296,6 +392,7 @@ private void EnsureHeadlessInput() { if (BasisHeadlessInput != null) { + BasisHeadlessInput.StopMovement(); return; } @@ -310,13 +407,227 @@ private void EnsureHeadlessInput() BasisHeadlessInput = gameObject.AddComponent(); BasisHeadlessInput.Initialize("Desktop Eye", nameof(Basis.Scripts.Device_Management.Devices.Headless.BasisHeadlessInput)); + BasisHeadlessInput.StopMovement(); BasisDeviceManagement.Instance.TryAdd(BasisHeadlessInput); } + + private void ApplyRuntimeConfiguration() + { + BasisHeadlessRuntimeStatus.ApplyConfiguration( + Ip, + Port, + HealthCheckEnabled, + HealthCheckHost, + HealthCheckPort, + HealthPath, + ReconnectEnabled, + ReconnectDelaySeconds, + MaxReconnectAttempts); + } + + private void StartHealthEndpoint() + { + if (!HealthCheckEnabled) + { + StopHealthEndpoint(); + BasisHeadlessRuntimeStatus.SetHealthListenerRunning(false); + return; + } + + if (healthCheck != null) + { + return; + } + + try + { + healthCheck = new BasisHeadlessHealthCheck(HealthCheckHost, HealthCheckPort, HealthPath); + BasisDebug.Log($"Headless health check started at http://{HealthCheckHost}:{HealthCheckPort}{HealthPath}", BasisDebug.LogTag.Networking); + } + catch (Exception ex) + { + BasisHeadlessRuntimeStatus.SetHealthListenerRunning(false); + BasisDebug.LogError($"Failed to start headless health check endpoint: {ex.Message}", BasisDebug.LogTag.Networking); + } + } + + private void StopHealthEndpoint() + { + healthCheck?.Dispose(); + healthCheck = null; + } + + private void AttemptConnect() + { + if (isShuttingDown || BasisNetworkManagement.Instance == null) + { + return; + } + + reconnectScheduled = false; + BasisHeadlessRuntimeStatus.MarkConnecting(); + BasisNetworkManagement.Instance.Ip = Ip; + BasisNetworkManagement.Instance.Password = Password; + BasisNetworkManagement.Instance.IsHostMode = false; + BasisNetworkManagement.Instance.Port = (ushort)Port; + BasisNetworkManagement.Instance.Connect(); + BasisDebug.Log("connecting to default"); + BasisMainMenu.Close(); + } + + private void OnDisconnectedAfterReboot(DisconnectInfo disconnectInfo) + { + string message = TryReadDisconnectMessage(disconnectInfo); + BasisHeadlessRuntimeStatus.MarkDisconnected(disconnectInfo, message); + + if (!ShouldRetry(disconnectInfo)) + { + return; + } + + _ = ScheduleReconnectAsync(disconnectInfo); + } + + private async Task ScheduleReconnectAsync(DisconnectInfo disconnectInfo) + { + if (reconnectCts == null || isShuttingDown) + { + return; + } + + if (reconnectScheduled) + { + return; + } + + reconnectScheduled = true; + CancellationToken token = reconnectCts.Token; + int nextAttempt = BasisHeadlessRuntimeStatus.CreateSnapshot().CurrentRetryAttempt + 1; + if (nextAttempt > MaxReconnectAttempts) + { + reconnectScheduled = false; + BasisHeadlessRuntimeStatus.MarkRetriesExhausted(); + BasisDebug.LogWarning("Headless reconnect attempts exhausted.", BasisDebug.LogTag.Networking); + return; + } + + BasisHeadlessRuntimeStatus.MarkReconnectScheduled(nextAttempt); + BasisDebug.LogWarning($"Headless reconnect attempt {nextAttempt}/{MaxReconnectAttempts} scheduled in {ReconnectDelaySeconds}s after {disconnectInfo.Reason}.", BasisDebug.LogTag.Networking); + + try + { + await Task.Delay(TimeSpan.FromSeconds(ReconnectDelaySeconds), token); + } + catch (TaskCanceledException) + { + reconnectScheduled = false; + return; + } + + if (token.IsCancellationRequested || isShuttingDown) + { + reconnectScheduled = false; + return; + } + + BasisDeviceManagement.EnqueueOnMainThread(() => + { + if (!isShuttingDown) + { + AttemptConnect(); + } + }); + } + + private void CancelReconnectLoop() + { + if (reconnectCts == null) + { + return; + } + + reconnectCts.Cancel(); + reconnectCts.Dispose(); + reconnectCts = null; + reconnectScheduled = false; + } + + private bool ShouldRetry(DisconnectInfo disconnectInfo) + { + if (isShuttingDown || BasisNetworkConnection.HeadlessReconnectSuppressed) + { + return false; + } + + if (!ReconnectEnabled || MaxReconnectAttempts <= 0) + { + return false; + } + + switch (disconnectInfo.Reason) + { + case DisconnectReason.ConnectionFailed: + case DisconnectReason.Timeout: + case DisconnectReason.HostUnreachable: + case DisconnectReason.NetworkUnreachable: + case DisconnectReason.UnknownHost: + case DisconnectReason.Reconnect: + case DisconnectReason.PeerNotFound: + return true; + case DisconnectReason.RemoteConnectionClose: + return !IsHardStopRemoteClose(TryReadDisconnectMessage(disconnectInfo)); + case DisconnectReason.DisconnectPeerCalled: + case DisconnectReason.ConnectionRejected: + case DisconnectReason.InvalidProtocol: + case DisconnectReason.PeerToPeerConnection: + return false; + default: + return false; + } + } + + private static bool IsHardStopRemoteClose(string message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return false; + } + + return message.IndexOf("reject", StringComparison.OrdinalIgnoreCase) >= 0 || + message.IndexOf("denied", StringComparison.OrdinalIgnoreCase) >= 0 || + message.IndexOf("auth", StringComparison.OrdinalIgnoreCase) >= 0 || + message.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 || + message.IndexOf("protocol", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static string TryReadDisconnectMessage(DisconnectInfo disconnectInfo) + { + try + { + if (disconnectInfo.AdditionalData != null && + disconnectInfo.AdditionalData.TryGetString(out string message)) + { + return message; + } + } + catch + { + } + + return null; + } #endif /// public override void StopSDK() { +#if UNITY_SERVER + isShuttingDown = true; + BasisNetworkConnection.HeadlessReconnectSuppressed = true; + CancelReconnectLoop(); + StopHealthEndpoint(); + BasisHeadlessRuntimeStatus.MarkStopping(); +#endif BasisDebug.Log(nameof(StopSDK), BasisDebug.LogTag.Device); } @@ -330,14 +641,11 @@ public override bool IsDeviceBootable(string BootRequest) return false; } - // Randomized name generation bits public static string[] adjectives = { "Swift", "Brave", "Clever", "Fierce", "Nimble", "Silent", "Bold", "Lucky", "Strong", "Mighty", "Sneaky", "Fearless", "Wise", "Vicious", "Daring" }; public static string[] nouns = { "Warrior", "Hunter", "Mage", "Rogue", "Paladin", "Shaman", "Knight", "Archer", "Monk", "Druid", "Assassin", "Sorcerer", "Ranger", "Guardian", "Berserker" }; public static string[] titles = { "the Swift", "the Bold", "the Silent", "the Brave", "the Fierce", "the Wise", "the Protector", "the Shadow", "the Flame", "the Phantom" }; - /// Animal list for name flair. public static string[] animals = { "Wolf", "Tiger", "Eagle", "Dragon", "Lion", "Bear", "Hawk", "Panther", "Raven", "Serpent", "Fox", "Falcon" }; - /// Unity rich-text color names with hex values. public static (string Name, string Hex)[] colors = { ("Red", "#FF0000"), @@ -373,3 +681,4 @@ public static string GenerateRandomPlayerName() return $"{generatedName}"; } } +#endif \ No newline at end of file diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessRuntimeStatus.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessRuntimeStatus.cs new file mode 100644 index 0000000000..d05115fe0c --- /dev/null +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessRuntimeStatus.cs @@ -0,0 +1,259 @@ +using Basis.Network.Core; +using System; + +public enum BasisHeadlessConnectionState +{ + Starting, + Connecting, + Connected, + Disconnected, + RetryScheduled, + RetriesExhausted, + Stopping +} + +public static class BasisHeadlessRuntimeStatus +{ + private static readonly object sync = new object(); + + public static bool IsHealthListenerRunning { get; private set; } + public static bool IsConnected { get; private set; } + public static bool IsConnecting { get; private set; } + public static bool IsRetrying { get; private set; } + public static bool RetryEnabled { get; private set; } + public static int CurrentRetryAttempt { get; private set; } + public static int MaxRetryAttempts { get; private set; } + public static int RetryDelaySeconds { get; private set; } + public static int TotalDisconnectCount { get; private set; } + public static int TotalReconnectSuccessCount { get; private set; } + public static string LastDisconnectReason { get; private set; } + public static string LastDisconnectSocketError { get; private set; } + public static string LastDisconnectMessage { get; private set; } + public static string ConfiguredServerIp { get; private set; } = "localhost"; + public static int ConfiguredServerPort { get; private set; } = 4296; + public static bool HealthCheckEnabled { get; private set; } + public static string HealthCheckHost { get; private set; } = "0.0.0.0"; + public static int HealthCheckPort { get; private set; } = 10666; + public static string HealthPath { get; private set; } = "/health"; + public static DateTimeOffset StartTimeUtc { get; private set; } = DateTimeOffset.UtcNow; + public static DateTimeOffset? LastConnectAttemptUtc { get; private set; } + public static DateTimeOffset? LastConnectedUtc { get; private set; } + public static DateTimeOffset? LastDisconnectedUtc { get; private set; } + public static DateTimeOffset LastHealthStateChangeUtc { get; private set; } = DateTimeOffset.UtcNow; + public static BasisHeadlessConnectionState State { get; private set; } = BasisHeadlessConnectionState.Starting; + + public static void Reset() + { + lock (sync) + { + IsHealthListenerRunning = false; + IsConnected = false; + IsConnecting = false; + IsRetrying = false; + RetryEnabled = false; + CurrentRetryAttempt = 0; + MaxRetryAttempts = 10; + RetryDelaySeconds = 5; + TotalDisconnectCount = 0; + TotalReconnectSuccessCount = 0; + LastDisconnectReason = null; + LastDisconnectSocketError = null; + LastDisconnectMessage = null; + ConfiguredServerIp = "localhost"; + ConfiguredServerPort = 4296; + HealthCheckEnabled = false; + HealthCheckHost = "0.0.0.0"; + HealthCheckPort = 10666; + HealthPath = "/health"; + StartTimeUtc = DateTimeOffset.UtcNow; + LastConnectAttemptUtc = null; + LastConnectedUtc = null; + LastDisconnectedUtc = null; + LastHealthStateChangeUtc = StartTimeUtc; + State = BasisHeadlessConnectionState.Starting; + } + } + + public static void ApplyConfiguration( + string configuredServerIp, + int configuredServerPort, + bool healthCheckEnabled, + string healthCheckHost, + int healthCheckPort, + string healthPath, + bool retryEnabled, + int retryDelaySeconds, + int maxRetryAttempts) + { + lock (sync) + { + ConfiguredServerIp = configuredServerIp; + ConfiguredServerPort = configuredServerPort; + HealthCheckEnabled = healthCheckEnabled; + HealthCheckHost = healthCheckHost; + HealthCheckPort = healthCheckPort; + HealthPath = healthPath; + RetryEnabled = retryEnabled; + RetryDelaySeconds = retryDelaySeconds; + MaxRetryAttempts = maxRetryAttempts; + } + } + + public static void SetHealthListenerRunning(bool isRunning) + { + lock (sync) + { + IsHealthListenerRunning = isRunning; + } + } + + public static void MarkConnecting() + { + lock (sync) + { + IsConnected = false; + IsConnecting = true; + IsRetrying = false; + LastConnectAttemptUtc = DateTimeOffset.UtcNow; + SetStateLocked(BasisHeadlessConnectionState.Connecting); + } + } + + public static void MarkConnected() + { + lock (sync) + { + if (CurrentRetryAttempt > 0) + { + TotalReconnectSuccessCount++; + } + + IsConnected = true; + IsConnecting = false; + IsRetrying = false; + CurrentRetryAttempt = 0; + LastConnectedUtc = DateTimeOffset.UtcNow; + SetStateLocked(BasisHeadlessConnectionState.Connected); + } + } + + public static void MarkReconnectScheduled(int attempt) + { + lock (sync) + { + IsConnected = false; + IsConnecting = false; + IsRetrying = true; + CurrentRetryAttempt = attempt; + SetStateLocked(BasisHeadlessConnectionState.RetryScheduled); + } + } + + public static void MarkDisconnected(DisconnectInfo disconnectInfo, string message) + { + lock (sync) + { + IsConnected = false; + IsConnecting = false; + IsRetrying = false; + TotalDisconnectCount++; + LastDisconnectedUtc = DateTimeOffset.UtcNow; + LastDisconnectReason = disconnectInfo.Reason.ToString(); + LastDisconnectSocketError = disconnectInfo.SocketErrorCode.ToString(); + LastDisconnectMessage = message; + SetStateLocked(BasisHeadlessConnectionState.Disconnected); + } + } + + public static void MarkRetriesExhausted() + { + lock (sync) + { + IsConnected = false; + IsConnecting = false; + IsRetrying = false; + SetStateLocked(BasisHeadlessConnectionState.RetriesExhausted); + } + } + + public static void MarkStopping() + { + lock (sync) + { + IsConnected = false; + IsConnecting = false; + IsRetrying = false; + SetStateLocked(BasisHeadlessConnectionState.Stopping); + } + } + + public static Snapshot CreateSnapshot() + { + lock (sync) + { + return new Snapshot + { + IsHealthListenerRunning = IsHealthListenerRunning, + IsConnected = IsConnected, + IsConnecting = IsConnecting, + IsRetrying = IsRetrying, + RetryEnabled = RetryEnabled, + CurrentRetryAttempt = CurrentRetryAttempt, + MaxRetryAttempts = MaxRetryAttempts, + RetryDelaySeconds = RetryDelaySeconds, + TotalDisconnectCount = TotalDisconnectCount, + TotalReconnectSuccessCount = TotalReconnectSuccessCount, + LastDisconnectReason = LastDisconnectReason, + LastDisconnectSocketError = LastDisconnectSocketError, + LastDisconnectMessage = LastDisconnectMessage, + ConfiguredServerIp = ConfiguredServerIp, + ConfiguredServerPort = ConfiguredServerPort, + HealthCheckEnabled = HealthCheckEnabled, + HealthCheckHost = HealthCheckHost, + HealthCheckPort = HealthCheckPort, + HealthPath = HealthPath, + StartTimeUtc = StartTimeUtc, + LastConnectAttemptUtc = LastConnectAttemptUtc, + LastConnectedUtc = LastConnectedUtc, + LastDisconnectedUtc = LastDisconnectedUtc, + LastHealthStateChangeUtc = LastHealthStateChangeUtc, + State = State + }; + } + } + + private static void SetStateLocked(BasisHeadlessConnectionState state) + { + State = state; + LastHealthStateChangeUtc = DateTimeOffset.UtcNow; + } + + public sealed class Snapshot + { + public bool IsHealthListenerRunning; + public bool IsConnected; + public bool IsConnecting; + public bool IsRetrying; + public bool RetryEnabled; + public int CurrentRetryAttempt; + public int MaxRetryAttempts; + public int RetryDelaySeconds; + public int TotalDisconnectCount; + public int TotalReconnectSuccessCount; + public string LastDisconnectReason; + public string LastDisconnectSocketError; + public string LastDisconnectMessage; + public string ConfiguredServerIp; + public int ConfiguredServerPort; + public bool HealthCheckEnabled; + public string HealthCheckHost; + public int HealthCheckPort; + public string HealthPath; + public DateTimeOffset StartTimeUtc; + public DateTimeOffset? LastConnectAttemptUtc; + public DateTimeOffset? LastConnectedUtc; + public DateTimeOffset? LastDisconnectedUtc; + public DateTimeOffset LastHealthStateChangeUtc; + public BasisHeadlessConnectionState State; + } +} diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessRuntimeStatus.cs.meta b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessRuntimeStatus.cs.meta new file mode 100644 index 0000000000..c0b67df2df --- /dev/null +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessRuntimeStatus.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c67d8655c954b44ba12ad41b7b82f97 \ No newline at end of file diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs index 4604feba11..087bdeefa2 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs @@ -24,6 +24,10 @@ public static class BasisNetworkConnection public static NetworkClient NetworkClient { get; set; } = new NetworkClient(); public static bool LocalPlayerIsConnected { get; set; } public static BasisNetworkServerRunner BasisNetworkServerRunner = null; +#if UNITY_SERVER + public static bool HeadlessReconnectSuppressed { get; set; } + public static Action OnDisconnectedAfterReboot; +#endif private static void LogErrorOutput(string msg) => BasisDebug.LogError(msg, BasisDebug.LogTag.Networking); private static void LogWarningOutput(string msg) => BasisDebug.LogWarning(msg); private static void LogOutput(string msg) => BasisDebug.Log(msg, BasisDebug.LogTag.Networking); @@ -140,12 +144,18 @@ public static void OnDestroy() private static void PeerConnectedEvent(NetPeer peer) { BasisDebug.Log("Success! Now setting up Networked Local Player"); +#if UNITY_SERVER + BasisHeadlessRuntimeStatus.MarkConnected(); +#endif BasisDeviceManagement.EnqueueOnMainThread(() => { BasisDebug.Log("PeerConnectedEvent On MainThread"); try { +#if UNITY_SERVER + Basis.Scripts.Device_Management.Devices.Headless.BasisHeadlessInput.Instance?.ResumeMovement(); +#endif LocalPlayerPeer = peer; ushort localPlayerID = (ushort)peer.RemoteId; @@ -193,8 +203,17 @@ public static void HandleDisconnection(NetPeer peer, DisconnectInfo disconnectIn { BasisDeviceManagement.EnqueueOnMainThread(async () => { +#if UNITY_SERVER + Basis.Scripts.Device_Management.Devices.Headless.BasisHeadlessInput.Instance?.StopMovement(); +#endif BasisNetworkAvatarCompressor.Dispose(); await BasisNetworkLifeCycle.RebootManagement(BasisNetworkManagement.Instance, true, peer, disconnectInfo); +#if UNITY_SERVER + if (!HeadlessReconnectSuppressed) + { + OnDisconnectedAfterReboot?.Invoke(disconnectInfo); + } +#endif OnRebootComplete?.Invoke(); }); } From 8b534c46cbc3f9e60dc91f438aff679d0e052ccf Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Sun, 29 Mar 2026 23:59:32 -0500 Subject: [PATCH 2/7] Disable the PIP CameraDriver and asset initialization. --- .../Devices/Headless/BasisHeadlessManagement.cs | 5 +++++ .../com.basis.framework/Networking/BasisNetworkLifeCycle.cs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs index 956e187ee8..0a01caa92f 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs @@ -311,6 +311,11 @@ public async void ConnectToNetwork() /// public async Task CreateAssetBundle() { +#if UNITY_SERVER + BasisDebug.Log("Skipping visual scene asset initialization on dedicated server build.", BasisDebug.LogTag.Networking); + await Task.CompletedTask; + return; +#endif if (BundledContentHolder.Instance.UseSceneProvidedHere) { BasisDebug.Log("using Local Asset Bundle or Addressable", BasisDebug.LogTag.Networking); diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkLifeCycle.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkLifeCycle.cs index e957a3edb9..5a57f7deb4 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkLifeCycle.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkLifeCycle.cs @@ -40,7 +40,9 @@ public static void Initalize(BasisNetworkManagement Management) Management.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); BasisJoinLeaveNotification.Create(); +#if !UNITY_SERVER BasisNetworkPIPCameraDriver.Create(); +#endif BasisNetworkManagement.OnEnableInstanceCreate?.Invoke(); BasisNetworkManagement.NetworkRunning = true; } @@ -137,7 +139,9 @@ public static async Task Destroy(BasisNetworkManagement Management) // let the MonoBehaviour reset its Instance in OnDestroy; no direct assignment here BasisDebug.Log("BasisNetworkManagement has been successfully shutdown.", BasisDebug.LogTag.Networking); BasisJoinLeaveNotification.Shutdown(); +#if !UNITY_SERVER BasisNetworkPIPCameraDriver.Shutdown(); +#endif BasisNetworkConnection.NetworkClient?.Disconnect(); } } From e3e9a7050faa3d90194d1f9f375d9e81af9047a8 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Mon, 30 Mar 2026 01:14:59 -0500 Subject: [PATCH 3/7] adding curl to docker file. --- .github/docker/headless/linux/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile index 87da22a38a..2b9a0cd1bb 100644 --- a/.github/docker/headless/linux/Dockerfile +++ b/.github/docker/headless/linux/Dockerfile @@ -6,6 +6,7 @@ RUN apt-get update && \ libglib2.0-0 \ libgtk-3-0 \ libx11-6 \ + curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /app From ce7bc2e34b0bdf36f33a66c23e9d34902b2feca2 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Mon, 30 Mar 2026 23:39:37 -0500 Subject: [PATCH 4/7] Add avatar load support. --- .github/workflows/build-docker-server.yml | 4 +- .../Headless/BasisHeadlessManagement.cs | 104 ++++++++++++++++-- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index c766d8d470..8c7ab04246 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -231,8 +231,10 @@ jobs: default_password server1.basisvr.org 4296 + + false - 0.0.0.0 + 127.0.0.1 10666 /health true diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs index 0a01caa92f..d6ebb07ce2 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; @@ -32,6 +33,8 @@ public class BasisHeadlessManagement : BasisBaseTypeManagement /// Server port loaded from config or default. public static int Port = 4296; + public static string AvatarFileLocation = string.Empty; + public static string AvatarPassword = string.Empty; public static bool HealthCheckEnabled = false; public static string HealthCheckHost = "0.0.0.0"; @@ -46,6 +49,7 @@ public class BasisHeadlessManagement : BasisBaseTypeManagement private bool isShuttingDown; private bool hasLoadedStartupContent; private bool reconnectScheduled; + private bool configuredAvatarApplied; /// /// Scene change hook used in headless to aggressively strip visuals and free memory. @@ -144,6 +148,8 @@ public static void LoadOrCreateConfigXml() string defaultPassword = Password; string defaultIp = Ip; int defaultPort = Port; + string defaultAvatarFileLocation = AvatarFileLocation; + string defaultAvatarPassword = AvatarPassword; bool defaultHealthCheckEnabled = HealthCheckEnabled; string defaultHealthCheckHost = HealthCheckHost; int defaultHealthCheckPort = HealthCheckPort; @@ -155,6 +161,8 @@ public static void LoadOrCreateConfigXml() string envPassword = ReadEnvironmentString("Password"); string envIp = ReadEnvironmentString("Ip"); int? envPort = ReadEnvironmentInt("Port"); + string envAvatarFileLocation = ReadEnvironmentString("AvatarFileLocation"); + string envAvatarPassword = ReadEnvironmentString("AvatarPassword"); bool? envHealthCheckEnabled = ReadEnvironmentBool("HealthCheckEnabled"); string envHealthCheckHost = ReadEnvironmentString("HealthCheckHost"); int? envHealthCheckPort = ReadEnvironmentInt("HealthCheckPort"); @@ -176,6 +184,8 @@ public static void LoadOrCreateConfigXml() defaultPassword, defaultIp, defaultPort, + defaultAvatarFileLocation, + defaultAvatarPassword, defaultHealthCheckEnabled, defaultHealthCheckHost, defaultHealthCheckPort, @@ -188,6 +198,8 @@ public static void LoadOrCreateConfigXml() Password = envPassword ?? root?.Element("Password")?.Value ?? defaultPassword; Ip = envIp ?? root?.Element("Ip")?.Value ?? defaultIp; Port = envPort ?? ReadXmlInt(root?.Element("Port")?.Value, defaultPort); + AvatarFileLocation = envAvatarFileLocation ?? root?.Element("AvatarFileLocation")?.Value ?? defaultAvatarFileLocation; + AvatarPassword = envAvatarPassword ?? root?.Element("AvatarPassword")?.Value ?? defaultAvatarPassword; HealthCheckEnabled = envHealthCheckEnabled ?? ReadXmlBool(root?.Element("HealthCheckEnabled")?.Value, defaultHealthCheckEnabled); HealthCheckHost = envHealthCheckHost ?? root?.Element("HealthCheckHost")?.Value ?? defaultHealthCheckHost; HealthCheckPort = envHealthCheckPort ?? ReadXmlInt(root?.Element("HealthCheckPort")?.Value, defaultHealthCheckPort); @@ -195,6 +207,7 @@ public static void LoadOrCreateConfigXml() ReconnectEnabled = envReconnectEnabled ?? ReadXmlBool(root?.Element("ReconnectEnabled")?.Value, defaultReconnectEnabled); ReconnectDelaySeconds = Mathf.Max(1, envReconnectDelaySeconds ?? ReadXmlInt(root?.Element("ReconnectDelaySeconds")?.Value, defaultReconnectDelaySeconds)); MaxReconnectAttempts = Mathf.Max(0, envMaxReconnectAttempts ?? ReadXmlInt(root?.Element("MaxReconnectAttempts")?.Value, defaultMaxReconnectAttempts)); + NormalizeConfiguredAvatarFields(); } private static void TryCreateDefaultConfigXml( @@ -202,6 +215,8 @@ private static void TryCreateDefaultConfigXml( string password, string ip, int port, + string avatarFileLocation, + string avatarPassword, bool healthCheckEnabled, string healthCheckHost, int healthCheckPort, @@ -216,6 +231,8 @@ private static void TryCreateDefaultConfigXml( new XElement("Password", password), new XElement("Ip", ip), new XElement("Port", port), + new XElement("AvatarFileLocation", avatarFileLocation ?? string.Empty), + new XElement("AvatarPassword", avatarPassword ?? string.Empty), new XElement("HealthCheckEnabled", healthCheckEnabled), new XElement("HealthCheckHost", healthCheckHost), new XElement("HealthCheckPort", healthCheckPort), @@ -287,6 +304,42 @@ private static bool ReadXmlBool(string value, bool fallback) return bool.TryParse(value, out bool parsed) ? parsed : fallback; } + private static void NormalizeConfiguredAvatarFields() + { + AvatarFileLocation = AvatarFileLocation?.Trim() ?? string.Empty; + AvatarPassword = AvatarPassword?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(AvatarFileLocation)) + { + AvatarFileLocation = string.Empty; + return; + } + + int fragmentIndex = AvatarFileLocation.IndexOf('#'); + if (fragmentIndex < 0) + { + return; + } + + string baseUrl = AvatarFileLocation[..fragmentIndex].Trim(); + string encodedPassword = AvatarFileLocation[(fragmentIndex + 1)..].Trim(); + AvatarFileLocation = baseUrl; + + if (!string.IsNullOrEmpty(AvatarPassword) || string.IsNullOrEmpty(encodedPassword)) + { + return; + } + + try + { + AvatarPassword = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPassword)); + } + catch (FormatException) + { + Debug.LogWarning("AvatarFileLocation contains a '#' fragment, but the suffix is not valid base64. Ignoring inline avatar password."); + } + } + /// /// Reads config, loads default scene once, and connects to network as client. /// @@ -311,11 +364,9 @@ public async void ConnectToNetwork() /// public async Task CreateAssetBundle() { -#if UNITY_SERVER BasisDebug.Log("Skipping visual scene asset initialization on dedicated server build.", BasisDebug.LogTag.Networking); await Task.CompletedTask; return; -#endif if (BundledContentHolder.Instance.UseSceneProvidedHere) { BasisDebug.Log("using Local Asset Bundle or Addressable", BasisDebug.LogTag.Networking); @@ -333,12 +384,14 @@ public async Task CreateAssetBundle() /// public override void StartSDK() { -#if UNITY_SERVER isShuttingDown = false; hasLoadedStartupContent = false; reconnectScheduled = false; + configuredAvatarApplied = false; reconnectCts = new CancellationTokenSource(); BasisHeadlessRuntimeStatus.Reset(); + LoadOrCreateConfigXml(); + ApplyRuntimeConfiguration(); BasisNetworkConnection.HeadlessReconnectSuppressed = false; BasisNetworkConnection.OnDisconnectedAfterReboot -= OnDisconnectedAfterReboot; BasisNetworkConnection.OnDisconnectedAfterReboot += OnDisconnectedAfterReboot; @@ -346,6 +399,7 @@ public override void StartSDK() if (BasisLocalPlayer.PlayerReady && BasisLocalPlayer.Instance != null) { EnsureHeadlessInput(); + _ = ApplyConfiguredAvatarAsync(); } else { @@ -367,13 +421,11 @@ public override void StartSDK() } SceneManager.activeSceneChanged += OnSceneLoadeded; -#endif BasisDebug.Log(nameof(StartSDK), BasisDebug.LogTag.Device); } private void OnDestroy() { -#if UNITY_SERVER isShuttingDown = true; BasisNetworkConnection.HeadlessReconnectSuppressed = true; BasisNetworkConnection.OnDisconnectedAfterReboot -= OnDisconnectedAfterReboot; @@ -383,14 +435,13 @@ private void OnDestroy() StopHealthEndpoint(); BasisHeadlessRuntimeStatus.MarkStopping(); BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; -#endif } -#if UNITY_SERVER private void OnLocalPlayerReadyForHeadless() { BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; EnsureHeadlessInput(); + _ = ApplyConfiguredAvatarAsync(); } private void EnsureHeadlessInput() @@ -430,6 +481,42 @@ private void ApplyRuntimeConfiguration() MaxReconnectAttempts); } + private async Task ApplyConfiguredAvatarAsync() + { + if (configuredAvatarApplied || BasisLocalPlayer.Instance == null) + { + return; + } + + if (string.IsNullOrWhiteSpace(AvatarFileLocation)) + { + configuredAvatarApplied = true; + return; + } + + configuredAvatarApplied = true; + + BasisLoadableBundle bundle = new BasisLoadableBundle + { + UnlockPassword = AvatarPassword ?? string.Empty, + BasisRemoteBundleEncrypted = new BasisRemoteEncyptedBundle + { + RemoteBeeFileLocation = AvatarFileLocation + }, + BasisLocalEncryptedBundle = new BasisStoredEncryptedBundle() + }; + + try + { + BasisDebug.Log($"Loading headless configured avatar {AvatarFileLocation}", BasisDebug.LogTag.Avatar); + await BasisLocalPlayer.Instance.CreateAvatar(BasisLocalPlayer.LoadModeLocal, bundle); + } + catch (Exception ex) + { + BasisDebug.LogError($"Failed to load configured headless avatar '{AvatarFileLocation}': {ex.Message}", BasisDebug.LogTag.Avatar); + } + } + private void StartHealthEndpoint() { if (!HealthCheckEnabled) @@ -621,18 +708,15 @@ private static string TryReadDisconnectMessage(DisconnectInfo disconnectInfo) return null; } -#endif /// public override void StopSDK() { -#if UNITY_SERVER isShuttingDown = true; BasisNetworkConnection.HeadlessReconnectSuppressed = true; CancelReconnectLoop(); StopHealthEndpoint(); BasisHeadlessRuntimeStatus.MarkStopping(); -#endif BasisDebug.Log(nameof(StopSDK), BasisDebug.LogTag.Device); } From eedd1020b84fdf7cd2980871f215d8992fa88522 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Mon, 30 Mar 2026 23:49:09 -0500 Subject: [PATCH 5/7] Add multi-platform aware cache. --- .../BasisBEEExtensionMeta.cs | 1 + .../BasisBeeManagement.cs | 45 ++++++++- .../BasisIOManagement.cs | 50 +++++++++- .../BasisLoadhandler.cs | 93 ++++++++++++++----- .../BasisStorageManagement.cs | 87 +++++++++++------ 5 files changed, 218 insertions(+), 58 deletions(-) diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisBEEExtensionMeta.cs b/Basis/Packages/com.basis.bundlemanagement/BasisBEEExtensionMeta.cs index 4217be46ce..0f0b556a88 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisBEEExtensionMeta.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisBEEExtensionMeta.cs @@ -7,4 +7,5 @@ public class BasisBEEExtensionMeta [SerializeField] public BasisStoredEncryptedBundle StoredLocal = new BasisStoredEncryptedBundle();//where we got bundle file from public string UniqueVersion; + public string DownloadedPlatform; } diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisBeeManagement.cs b/Basis/Packages/com.basis.bundlemanagement/BasisBeeManagement.cs index 8597c07537..aaaf255d10 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisBeeManagement.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisBeeManagement.cs @@ -5,6 +5,13 @@ using UnityEngine; public static class BasisBeeManagement { + private static bool HasCompatibleDownloadedPlatform(BasisBEEExtensionMeta metaInfo) + { + return metaInfo != null && + !string.IsNullOrEmpty(metaInfo.DownloadedPlatform) && + BasisBundleConnector.PlatformMatch(metaInfo.DownloadedPlatform); + } + /// /// this allows obtaining the entire bee file /// @@ -16,9 +23,16 @@ public static async Task HandleBundleAndMetaLoading(BasisTrackedBundleWrapper wr { bool IsMetaOnDisc = BasisLoadHandler.IsMetaDataOnDisc(wrapper.LoadableBundle.BasisRemoteBundleEncrypted.RemoteBeeFileLocation, out BasisBEEExtensionMeta MetaInfo); bool didForceRedownload = false; + bool shouldUseOnDiskMeta = IsMetaOnDisc; + + if (shouldUseOnDiskMeta && !HasCompatibleDownloadedPlatform(MetaInfo) && !string.IsNullOrEmpty(MetaInfo.DownloadedPlatform)) + { + BasisDebug.Log($"Cached bundle platform {MetaInfo.DownloadedPlatform} does not match {Application.platform}. Forcing re-download.", BasisDebug.LogTag.Event); + shouldUseOnDiskMeta = false; + } (BasisBundleGenerated, byte[], string) output; - if (IsMetaOnDisc) + if (shouldUseOnDiskMeta) { BasisDebug.Log("Process On Disc Meta Data Async", BasisDebug.LogTag.Event); output = await BasisBundleManagement.LocalLoadBundleConnector(wrapper, MetaInfo.StoredLocal, report, cancellationToken); @@ -54,7 +68,7 @@ public static async Task HandleBundleAndMetaLoading(BasisTrackedBundleWrapper wr { wrapper.AssetBundle = assetBundle; BasisDebug.Log($"we already have this AssetToLoadName in our loaded bundles using that instead! {AssetToLoadName}"); - await SaveMetaIfNeeded(wrapper, IsMetaOnDisc, didForceRedownload); + await SaveMetaIfNeeded(wrapper, shouldUseOnDiskMeta, didForceRedownload, output.Item1.Platform); return; } } @@ -63,10 +77,31 @@ public static async Task HandleBundleAndMetaLoading(BasisTrackedBundleWrapper wr try { AssetBundleCreateRequest bundleRequest = await BasisEncryptionToData.GenerateBundleFromFile(wrapper.LoadableBundle.UnlockPassword, output.Item2, output.Item1.AssetBundleCRC, report); + if (bundleRequest == null || bundleRequest.assetBundle == null) + { + if (shouldUseOnDiskMeta && !didForceRedownload) + { + BasisDebug.Log("Cached bundle bytes failed to load; forcing re-download.", BasisDebug.LogTag.Event); + output = await BasisBundleManagement.DownloadLoadBundleConnector(wrapper, report, cancellationToken, MaxDownloadSizeInBytes); + didForceRedownload = true; + + if (output.Item1 == null || output.Item2 == null || output.Item2.Length == 0 || !string.IsNullOrEmpty(output.Item3)) + { + throw new Exception($"Unable to reload bundle after cache mismatch. {output.Item3}"); + } + + bundleRequest = await BasisEncryptionToData.GenerateBundleFromFile(wrapper.LoadableBundle.UnlockPassword, output.Item2, output.Item1.AssetBundleCRC, report); + } + + if (bundleRequest == null || bundleRequest.assetBundle == null) + { + throw new Exception("AssetBundle creation failed after attempting to refresh the cached bundle."); + } + } wrapper.AssetBundle = bundleRequest.assetBundle; - await SaveMetaIfNeeded(wrapper, IsMetaOnDisc, didForceRedownload); + await SaveMetaIfNeeded(wrapper, shouldUseOnDiskMeta, didForceRedownload, output.Item1.Platform); } catch (Exception ex) { @@ -76,7 +111,7 @@ public static async Task HandleBundleAndMetaLoading(BasisTrackedBundleWrapper wr /// /// Saves or updates on-disc metadata when it is missing or was refreshed by a forced re-download. /// - private static async Task SaveMetaIfNeeded(BasisTrackedBundleWrapper wrapper, bool wasMetaOnDisc, bool didForceRedownload) + private static async Task SaveMetaIfNeeded(BasisTrackedBundleWrapper wrapper, bool wasMetaOnDisc, bool didForceRedownload, string downloadedPlatform) { if (!wasMetaOnDisc || didForceRedownload) { @@ -85,6 +120,7 @@ private static async Task SaveMetaIfNeeded(BasisTrackedBundleWrapper wrapper, bo StoredRemote = wrapper.LoadableBundle.BasisRemoteBundleEncrypted, StoredLocal = wrapper.LoadableBundle.BasisLocalEncryptedBundle, UniqueVersion = wrapper.LoadableBundle.BasisBundleConnector.UniqueVersion, + DownloadedPlatform = downloadedPlatform, }; await BasisLoadHandler.AddDiscInfo(newDiscInfo); @@ -124,6 +160,7 @@ public static async Task HandleMetaOnlyLoad(BasisTrackedBundleWrapper wrap StoredRemote = wrapper.LoadableBundle.BasisRemoteBundleEncrypted, StoredLocal = wrapper.LoadableBundle.BasisLocalEncryptedBundle, UniqueVersion = wrapper.LoadableBundle.BasisBundleConnector.UniqueVersion, + DownloadedPlatform = BasisIOManagement.GetCurrentCachePlatform(), }; await BasisLoadHandler.AddDiscInfo(newDiscInfo); diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs b/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs index 386e6eb4f8..d8decf01d2 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs @@ -7,6 +7,48 @@ public static class BasisIOManagement { + public static string GetCurrentCachePlatform() + { + return Application.platform.ToString(); + } + + public static string GetBeeCacheFilePath(string uniqueVersion, string downloadedPlatform = null) + { + return GenerateFilePath(BuildPlatformAwareCacheFileName(uniqueVersion, BasisBeeConstants.BasisEncryptedExtension, downloadedPlatform), BasisBeeConstants.AssetBundlesFolder); + } + + public static string GetMetaCacheFilePath(string uniqueVersion, string downloadedPlatform = null) + { + return GenerateFilePath(BuildPlatformAwareCacheFileName(uniqueVersion, BasisBeeConstants.BasisMetaExtension, downloadedPlatform), BasisBeeConstants.AssetBundlesFolder); + } + + public static string GetLegacyBeeCacheFilePath(string uniqueVersion) + { + return GenerateFilePath($"{uniqueVersion}{BasisBeeConstants.BasisEncryptedExtension}", BasisBeeConstants.AssetBundlesFolder); + } + + public static string GetLegacyMetaCacheFilePath(string uniqueVersion) + { + return GenerateFilePath($"{uniqueVersion}{BasisBeeConstants.BasisMetaExtension}", BasisBeeConstants.AssetBundlesFolder); + } + + private static string BuildPlatformAwareCacheFileName(string uniqueVersion, string extension, string downloadedPlatform) + { + if (string.IsNullOrWhiteSpace(uniqueVersion)) + throw new ArgumentException("Unique version is null or empty.", nameof(uniqueVersion)); + + string normalizedPlatform = string.IsNullOrWhiteSpace(downloadedPlatform) + ? GetCurrentCachePlatform() + : downloadedPlatform.Trim(); + + foreach (char invalidChar in Path.GetInvalidFileNameChars()) + { + normalizedPlatform = normalizedPlatform.Replace(invalidChar, '_'); + } + + return $"{uniqueVersion}.{normalizedPlatform}{extension}"; + } + public sealed class BeeDownloadResult { public BasisBundleConnector Connector { get; } @@ -156,14 +198,14 @@ public static async Task> DownloadBEEEx(string url, } // 5) Write local .bee (Int32 header + connector + section) - string fileName = $"{connector.UniqueVersion}{BasisBeeConstants.BasisEncryptedExtension}"; + string fileName = Path.GetFileName(GetBeeCacheFilePath(connector.UniqueVersion)); if (string.IsNullOrWhiteSpace(fileName)) return BeeResult.Fail("DownloadBEEEx: Connector has no UniqueVersion / file extension."); string localPath; try { - localPath = GenerateFilePath(fileName, BasisBeeConstants.AssetBundlesFolder); + localPath = GetBeeCacheFilePath(connector.UniqueVersion); } catch (Exception ex) { @@ -227,13 +269,13 @@ public static async Task> DownloadBEEEx(string url, } // 5) Write local .bee (Int32 header + connector + section) - string fileName = $"{connector.UniqueVersion}{BasisBeeConstants.BasisEncryptedExtension}"; + string fileName = Path.GetFileName(GetBeeCacheFilePath(connector.UniqueVersion)); if (string.IsNullOrWhiteSpace(fileName)) { return BeeResult<(BasisBundleConnector, string)>.Fail("DownloadBEEEx: Connector has no UniqueVersion / file extension."); } - string localPath = GenerateFilePath(fileName, BasisBeeConstants.AssetBundlesFolder); + string localPath = GetBeeCacheFilePath(connector.UniqueVersion); var connectorBytes = connectorRes.Value.Data; var writeRes = await WriteBeeFileAsync(localPath, connectorBytes, null, true); diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisLoadhandler.cs b/Basis/Packages/com.basis.bundlemanagement/BasisLoadhandler.cs index f59f47b94a..ac6a19d86f 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisLoadhandler.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisLoadhandler.cs @@ -167,39 +167,84 @@ private static async Task HandleFirstBundleLoad(GameObject DisabledG BasisDebug.LogError($"{ex.Message} {ex.StackTrace}"); LoadedBundles.Remove(loadableBundle.BasisRemoteBundleEncrypted.RemoteBeeFileLocation, out var data); CleanupFiles(loadableBundle.BasisLocalEncryptedBundle); - OnDiscData.TryRemove(loadableBundle.BasisRemoteBundleEncrypted.RemoteBeeFileLocation, out _); return null; } } + + public static string GetDiscInfoKey(string remoteUrl, string downloadedPlatform) + { + string safeUrl = remoteUrl ?? string.Empty; + string safePlatform = string.IsNullOrWhiteSpace(downloadedPlatform) ? "legacy" : downloadedPlatform.Trim(); + return $"{safePlatform}|{safeUrl}"; + } + + private static bool TryResolveStoredBeePath(BasisBEEExtensionMeta discInfo, out string beePath) + { + beePath = discInfo.StoredLocal.DownloadedBeeFileLocation; + if (!string.IsNullOrWhiteSpace(beePath) && File.Exists(beePath)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(discInfo.UniqueVersion)) + { + string platformAwarePath = BasisIOManagement.GetBeeCacheFilePath(discInfo.UniqueVersion, discInfo.DownloadedPlatform); + if (File.Exists(platformAwarePath)) + { + discInfo.StoredLocal.DownloadedBeeFileLocation = platformAwarePath; + beePath = platformAwarePath; + return true; + } + + string legacyPath = BasisIOManagement.GetLegacyBeeCacheFilePath(discInfo.UniqueVersion); + if (File.Exists(legacyPath)) + { + discInfo.StoredLocal.DownloadedBeeFileLocation = legacyPath; + beePath = legacyPath; + return true; + } + } + + beePath = null; + return false; + } + public static bool IsMetaDataOnDisc(string MetaURL, out BasisBEEExtensionMeta info) { lock (_discInfoLock) { + string currentPlatform = BasisIOManagement.GetCurrentCachePlatform(); + BasisBEEExtensionMeta legacyCandidate = null; + foreach (var discInfo in OnDiscData.Values) { if (discInfo.StoredRemote.RemoteBeeFileLocation == MetaURL) { - info = discInfo; - - if (string.IsNullOrEmpty(discInfo.StoredLocal.DownloadedBeeFileLocation)) + if (!string.IsNullOrWhiteSpace(discInfo.DownloadedPlatform) && + !string.Equals(discInfo.DownloadedPlatform, currentPlatform, StringComparison.OrdinalIgnoreCase)) { - string BEEPath = BasisIOManagement.GenerateFilePath($"{info.UniqueVersion}{BasisBeeConstants.BasisEncryptedExtension}", BasisBeeConstants.AssetBundlesFolder); - if (File.Exists(BEEPath)) - { - discInfo.StoredLocal.DownloadedBeeFileLocation = BEEPath; - return true; - } + continue; } - else + + if (TryResolveStoredBeePath(discInfo, out _)) { - if (File.Exists(discInfo.StoredLocal.DownloadedBeeFileLocation)) + if (string.Equals(discInfo.DownloadedPlatform, currentPlatform, StringComparison.OrdinalIgnoreCase)) { + info = discInfo; return true; } + + legacyCandidate = discInfo; } } } + if (legacyCandidate != null) + { + info = legacyCandidate; + return true; + } + info = new BasisBEEExtensionMeta(); return false; } @@ -207,19 +252,21 @@ public static bool IsMetaDataOnDisc(string MetaURL, out BasisBEEExtensionMeta in public static async Task AddDiscInfo(BasisBEEExtensionMeta discInfo) { - if (OnDiscData.TryAdd(discInfo.StoredRemote.RemoteBeeFileLocation, discInfo)) - { - } - else - { - OnDiscData[discInfo.StoredRemote.RemoteBeeFileLocation] = discInfo; - BasisDebug.Log("Disc info updated.", BasisDebug.LogTag.Event); - } - string filePath = BasisIOManagement.GenerateFilePath($"{discInfo.UniqueVersion}{BasisBeeConstants.BasisMetaExtension}", BasisBeeConstants.AssetBundlesFolder); + string discKey = GetDiscInfoKey(discInfo.StoredRemote.RemoteBeeFileLocation, discInfo.DownloadedPlatform); + OnDiscData[discKey] = discInfo; + string filePath = BasisIOManagement.GetMetaCacheFilePath(discInfo.UniqueVersion, discInfo.DownloadedPlatform); byte[] serializedData = BasisSerialization.SerializeValue(discInfo); try { + if (!string.IsNullOrWhiteSpace(discInfo.UniqueVersion)) + { + string legacyMetaPath = BasisIOManagement.GetLegacyMetaCacheFilePath(discInfo.UniqueVersion); + if (File.Exists(legacyMetaPath)) + { + File.Delete(legacyMetaPath); + } + } if (File.Exists(filePath)) { File.Delete(filePath); @@ -275,7 +322,7 @@ private static async Task LoadAllDiscData() { byte[] fileData = await File.ReadAllBytesAsync(file); BasisBEEExtensionMeta discInfo = BasisSerialization.DeserializeValue(fileData); - OnDiscData.TryAdd(discInfo.StoredRemote.RemoteBeeFileLocation, discInfo); + OnDiscData[GetDiscInfoKey(discInfo.StoredRemote.RemoteBeeFileLocation, discInfo.DownloadedPlatform)] = discInfo; } catch (Exception ex) { @@ -292,7 +339,7 @@ private static async Task LoadAllDiscData() private static void CleanupFiles(BasisStoredEncryptedBundle bundle) { - if (File.Exists(bundle.DownloadedBeeFileLocation)) + if (!string.IsNullOrWhiteSpace(bundle?.DownloadedBeeFileLocation) && File.Exists(bundle.DownloadedBeeFileLocation)) { File.Delete(bundle.DownloadedBeeFileLocation); } diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisStorageManagement.cs b/Basis/Packages/com.basis.bundlemanagement/BasisStorageManagement.cs index d7de86b5cd..f49d5d3c73 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisStorageManagement.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisStorageManagement.cs @@ -26,10 +26,12 @@ public static class BasisStorageManagement /// public class StoredBeeFileInfo { + public string DiscKey; public string RemoteUrl; public string LocalPath; public string MetaPath; public string UniqueVersion; + public string DownloadedPlatform; public long FileSizeBytes; public DateTime LastWriteTimeUtc; public bool IsLoadedInMemory; @@ -72,20 +74,18 @@ public static List GetAllStoredFiles() foreach (var kvp in BasisLoadHandler.OnDiscData) { + string discKey = kvp.Key; string remoteUrl = kvp.Key; BasisBEEExtensionMeta meta = kvp.Value; + remoteUrl = meta.StoredRemote.RemoteBeeFileLocation; string beePath = meta.StoredLocal.DownloadedBeeFileLocation; if (string.IsNullOrEmpty(beePath)) { - beePath = BasisIOManagement.GenerateFilePath( - $"{meta.UniqueVersion}{BasisBeeConstants.BasisEncryptedExtension}", - BasisBeeConstants.AssetBundlesFolder); + beePath = BasisIOManagement.GetBeeCacheFilePath(meta.UniqueVersion, meta.DownloadedPlatform); } - string metaFilePath = BasisIOManagement.GenerateFilePath( - $"{meta.UniqueVersion}{BasisBeeConstants.BasisMetaExtension}", - BasisBeeConstants.AssetBundlesFolder); + string metaFilePath = BasisIOManagement.GetMetaCacheFilePath(meta.UniqueVersion, meta.DownloadedPlatform); long fileSize = 0; DateTime lastWrite = DateTime.MinValue; @@ -108,10 +108,12 @@ public static List GetAllStoredFiles() result.Add(new StoredBeeFileInfo { + DiscKey = discKey, RemoteUrl = remoteUrl, LocalPath = beePath, MetaPath = metaFilePath, UniqueVersion = meta.UniqueVersion, + DownloadedPlatform = meta.DownloadedPlatform, FileSizeBytes = fileSize, LastWriteTimeUtc = lastWrite, IsLoadedInMemory = isLoaded, @@ -133,28 +135,21 @@ public static bool DeleteStoredFile(string remoteUrl) if (string.IsNullOrEmpty(remoteUrl)) return false; - // Remove from OnDiscData and get the meta info - if (BasisLoadHandler.OnDiscData.TryRemove(remoteUrl, out BasisBEEExtensionMeta meta)) + bool removedAny = false; + foreach (var kvp in BasisLoadHandler.OnDiscData.ToList()) { - // Delete the .BEE file - string beePath = meta.StoredLocal.DownloadedBeeFileLocation; - if (string.IsNullOrEmpty(beePath)) + if (!string.Equals(kvp.Value.StoredRemote.RemoteBeeFileLocation, remoteUrl, StringComparison.Ordinal)) { - beePath = BasisIOManagement.GenerateFilePath( - $"{meta.UniqueVersion}{BasisBeeConstants.BasisEncryptedExtension}", - BasisBeeConstants.AssetBundlesFolder); + continue; } - TryDeleteFile(beePath); - // Delete the .BME meta file - string metaPath = BasisIOManagement.GenerateFilePath( - $"{meta.UniqueVersion}{BasisBeeConstants.BasisMetaExtension}", - BasisBeeConstants.AssetBundlesFolder); - TryDeleteFile(metaPath); - - BasisDebug.Log($"Deleted stored BEE file: {meta.UniqueVersion} (source: {remoteUrl})", BasisDebug.LogTag.Event); + if (DeleteStoredEntry(kvp.Key, kvp.Value)) + { + removedAny = true; + } } - else + + if (!removedAny) { BasisDebug.LogWarning($"No OnDiscData entry found for URL: {remoteUrl}", BasisDebug.LogTag.Event); return false; @@ -186,11 +181,27 @@ public static bool DeleteStoredFile(string remoteUrl) public static void ClearAllCache() { // Get all keys first to avoid concurrent modification - var keys = BasisLoadHandler.OnDiscData.Keys.ToList(); + var entries = BasisLoadHandler.OnDiscData.ToList(); - foreach (string key in keys) + foreach (var entry in entries) { - DeleteStoredFile(key); + DeleteStoredEntry(entry.Key, entry.Value); + } + + foreach (var loadedEntry in BasisLoadHandler.LoadedBundles.ToList()) + { + if (BasisLoadHandler.LoadedBundles.TryRemove(loadedEntry.Key, out BasisTrackedBundleWrapper wrapper) && + wrapper?.AssetBundle != null) + { + try + { + wrapper.AssetBundle.Unload(true); + } + catch (Exception ex) + { + BasisDebug.LogError($"Error unloading AssetBundle during cache clear: {ex.Message}"); + } + } } // Also clean up any orphaned files not tracked in OnDiscData @@ -229,7 +240,8 @@ public static void EnforceCacheSizeLimit() break; long freed = file.FileSizeBytes; - if (DeleteStoredFile(file.RemoteUrl)) + if (BasisLoadHandler.OnDiscData.TryGetValue(file.DiscKey, out BasisBEEExtensionMeta meta) && + DeleteStoredEntry(file.DiscKey, meta)) { currentSize -= freed; BasisDebug.Log($"Evicted: {file.UniqueVersion} ({FormatBytes(freed)}). Remaining: {FormatBytes(currentSize)}", BasisDebug.LogTag.Event); @@ -259,6 +271,27 @@ private static string GetCacheFolderPath() return BasisIOManagement.GenerateFolderPath(BasisBeeConstants.AssetBundlesFolder); } + private static bool DeleteStoredEntry(string discKey, BasisBEEExtensionMeta meta) + { + if (!BasisLoadHandler.OnDiscData.TryRemove(discKey, out _)) + { + return false; + } + + string beePath = meta.StoredLocal.DownloadedBeeFileLocation; + if (string.IsNullOrEmpty(beePath)) + { + beePath = BasisIOManagement.GetBeeCacheFilePath(meta.UniqueVersion, meta.DownloadedPlatform); + } + TryDeleteFile(beePath); + + string metaPath = BasisIOManagement.GetMetaCacheFilePath(meta.UniqueVersion, meta.DownloadedPlatform); + TryDeleteFile(metaPath); + + BasisDebug.Log($"Deleted stored BEE file: {meta.UniqueVersion} [{meta.DownloadedPlatform}] (source: {meta.StoredRemote.RemoteBeeFileLocation})", BasisDebug.LogTag.Event); + return true; + } + private static void TryDeleteFile(string path) { if (string.IsNullOrEmpty(path)) From dedc52bc443c762e6852a0fdeb3735e6c7023733 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 31 Mar 2026 00:28:55 -0500 Subject: [PATCH 6/7] Have headless resolve Url in avatar load. --- .../Headless/BasisHeadlessManagement.cs | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs index d6ebb07ce2..f620cb27d0 100644 --- a/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs +++ b/Basis/Packages/com.basis.framework/Device Management/Devices/Headless/BasisHeadlessManagement.cs @@ -1,5 +1,7 @@ using Basis.BasisUI; using Basis.Network.Core; +using Basis.Scripts.Avatar; +using Basis.Scripts.Common; using Basis.Scripts.BasisSdk.Players; using Basis.Scripts.Device_Management; using Basis.Scripts.Device_Management.Devices.Headless; @@ -488,7 +490,7 @@ private async Task ApplyConfiguredAvatarAsync() return; } - if (string.IsNullOrWhiteSpace(AvatarFileLocation)) + if (!TryResolveHeadlessAvatarSelection(out string avatarLocation, out byte avatarLoadMode, out string avatarPassword, out string avatarSource)) { configuredAvatarApplied = true; return; @@ -498,23 +500,75 @@ private async Task ApplyConfiguredAvatarAsync() BasisLoadableBundle bundle = new BasisLoadableBundle { - UnlockPassword = AvatarPassword ?? string.Empty, + UnlockPassword = avatarPassword ?? string.Empty, BasisRemoteBundleEncrypted = new BasisRemoteEncyptedBundle { - RemoteBeeFileLocation = AvatarFileLocation + RemoteBeeFileLocation = avatarLocation }, BasisLocalEncryptedBundle = new BasisStoredEncryptedBundle() }; try { - BasisDebug.Log($"Loading headless configured avatar {AvatarFileLocation}", BasisDebug.LogTag.Avatar); - await BasisLocalPlayer.Instance.CreateAvatar(BasisLocalPlayer.LoadModeLocal, bundle); + BasisDebug.Log($"Loading headless avatar from {avatarSource}: {avatarLocation} (mode {avatarLoadMode})", BasisDebug.LogTag.Avatar); + await BasisLocalPlayer.Instance.CreateAvatar(avatarLoadMode, bundle); } catch (Exception ex) { - BasisDebug.LogError($"Failed to load configured headless avatar '{AvatarFileLocation}': {ex.Message}", BasisDebug.LogTag.Avatar); + BasisDebug.LogError($"Failed to load headless avatar from {avatarSource} '{avatarLocation}': {ex.Message}", BasisDebug.LogTag.Avatar); + } + } + + private static bool TryResolveHeadlessAvatarSelection(out string avatarLocation, out byte avatarLoadMode, out string avatarPassword, out string avatarSource) + { + if (!string.IsNullOrWhiteSpace(AvatarFileLocation)) + { + avatarLocation = AvatarFileLocation; + avatarLoadMode = ResolveAvatarLoadMode(avatarLocation, BasisPlayer.LoadModeLocal); + avatarPassword = AvatarPassword ?? string.Empty; + avatarSource = "headless override"; + return true; + } + + if (BasisDataStore.LoadAvatar( + BasisLocalPlayer.LoadFileNameAndExtension, + BasisBeeConstants.DefaultAvatar, + BasisPlayer.LoadModeLocal, + out BasisDataStore.BasisSavedAvatar savedAvatar) && + !string.IsNullOrWhiteSpace(savedAvatar?.UniqueID)) + { + avatarLocation = savedAvatar.UniqueID; + avatarLoadMode = ResolveAvatarLoadMode(avatarLocation, savedAvatar.loadmode); + avatarPassword = string.Empty; + avatarSource = BasisLocalPlayer.LoadFileNameAndExtension; + return true; + } + + avatarLocation = string.Empty; + avatarLoadMode = BasisPlayer.LoadModeLocal; + avatarPassword = string.Empty; + avatarSource = string.Empty; + return false; + } + + private static byte ResolveAvatarLoadMode(string avatarLocation, byte fallbackMode) + { + if (IsRemoteUrl(avatarLocation)) + { + return BasisPlayer.LoadModeNetworkDownloadable; } + + return fallbackMode; + } + + private static bool IsRemoteUrl(string avatarLocation) + { + if (!Uri.TryCreate(avatarLocation, UriKind.Absolute, out Uri uri)) + { + return false; + } + + return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; } private void StartHealthEndpoint() From e35e4c32de93b97072902042ed2552d00e13e1cc Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 31 Mar 2026 03:09:27 -0500 Subject: [PATCH 7/7] Reset these to developer. --- .../BasisBeeManagement.cs | 2 +- .../BasisIOManagement.cs | 23 ++--- .../BasisLoadhandler.cs | 98 +++++++++---------- 3 files changed, 56 insertions(+), 67 deletions(-) diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisBeeManagement.cs b/Basis/Packages/com.basis.bundlemanagement/BasisBeeManagement.cs index aaaf255d10..313b9eac24 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisBeeManagement.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisBeeManagement.cs @@ -9,7 +9,7 @@ private static bool HasCompatibleDownloadedPlatform(BasisBEEExtensionMeta metaIn { return metaInfo != null && !string.IsNullOrEmpty(metaInfo.DownloadedPlatform) && - BasisBundleConnector.PlatformMatch(metaInfo.DownloadedPlatform); + BasisIOManagement.CachePlatformMatchesCurrent(metaInfo.DownloadedPlatform); } /// diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs b/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs index fac4e2bc62..86e5e5c666 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisIOManagement.cs @@ -43,12 +43,12 @@ public static string NormalizeCachePlatformName(string platformName) public static string GetBeeCacheFilePath(string uniqueVersion, string downloadedPlatform = null) { - return GenerateFilePath(BuildLegacyCacheFileName(uniqueVersion, BasisBeeConstants.BasisEncryptedExtension), GetPlatformCacheFolder(downloadedPlatform)); + return GenerateFilePath(BuildPlatformAwareCacheFileName(uniqueVersion, BasisBeeConstants.BasisEncryptedExtension, downloadedPlatform), BasisBeeConstants.AssetBundlesFolder); } public static string GetMetaCacheFilePath(string uniqueVersion, string downloadedPlatform = null) { - return GenerateFilePath(BuildLegacyCacheFileName(uniqueVersion, BasisBeeConstants.BasisMetaExtension), GetPlatformCacheFolder(downloadedPlatform)); + return GenerateFilePath(BuildPlatformAwareCacheFileName(uniqueVersion, BasisBeeConstants.BasisMetaExtension, downloadedPlatform), BasisBeeConstants.AssetBundlesFolder); } public static string GetLegacyBeeCacheFilePath(string uniqueVersion) @@ -61,26 +61,21 @@ public static string GetLegacyMetaCacheFilePath(string uniqueVersion) return GenerateFilePath($"{uniqueVersion}{BasisBeeConstants.BasisMetaExtension}", BasisBeeConstants.AssetBundlesFolder); } - private static string GetPlatformCacheFolder(string downloadedPlatform) + private static string BuildPlatformAwareCacheFileName(string uniqueVersion, string extension, string downloadedPlatform) { + if (string.IsNullOrWhiteSpace(uniqueVersion)) + throw new ArgumentException("Unique version is null or empty.", nameof(uniqueVersion)); + string normalizedPlatform = string.IsNullOrWhiteSpace(downloadedPlatform) ? GetCurrentCachePlatform() : NormalizeCachePlatformName(downloadedPlatform); - foreach (char invalidChar in Path.GetInvalidPathChars()) + foreach (char invalidChar in Path.GetInvalidFileNameChars()) { normalizedPlatform = normalizedPlatform.Replace(invalidChar, '_'); } - return Path.Combine(BasisBeeConstants.AssetBundlesFolder, normalizedPlatform); - } - - private static string BuildLegacyCacheFileName(string uniqueVersion, string extension) - { - if (string.IsNullOrWhiteSpace(uniqueVersion)) - throw new ArgumentException("Unique version is null or empty.", nameof(uniqueVersion)); - - return $"{uniqueVersion}{extension}"; + return $"{uniqueVersion}.{normalizedPlatform}{extension}"; } public sealed class BeeDownloadResult @@ -855,4 +850,4 @@ private static string BuildNetworkErrorDetail(UnityWebRequest req) } return "No response header details available."; } -} \ No newline at end of file +} diff --git a/Basis/Packages/com.basis.bundlemanagement/BasisLoadhandler.cs b/Basis/Packages/com.basis.bundlemanagement/BasisLoadhandler.cs index 2f243f7746..7fe2b6fc27 100644 --- a/Basis/Packages/com.basis.bundlemanagement/BasisLoadhandler.cs +++ b/Basis/Packages/com.basis.bundlemanagement/BasisLoadhandler.cs @@ -209,43 +209,60 @@ private static bool TryResolveStoredBeePath(BasisBEEExtensionMeta discInfo, out return false; } - private static bool TryLoadDiscInfoFromFile(string filePath, string expectedRemoteUrl, out BasisBEEExtensionMeta info) + private static bool TryLazyLoadDiscInfo(string metaUrl, string currentPlatform, out BasisBEEExtensionMeta info) { info = null; - if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + + string path = BasisIOManagement.GenerateFolderPath(BasisBeeConstants.AssetBundlesFolder); + if (!Directory.Exists(path)) { return false; } - try + BasisBEEExtensionMeta legacyCandidate = null; + + foreach (string file in Directory.GetFiles(path, $"*{BasisBeeConstants.BasisMetaExtension}")) { - byte[] fileData = File.ReadAllBytes(filePath); - BasisBEEExtensionMeta discInfo = BasisSerialization.DeserializeValue(fileData); - if (discInfo?.StoredRemote?.RemoteBeeFileLocation == null) + try { - return false; - } + byte[] fileData = File.ReadAllBytes(file); + BasisBEEExtensionMeta discInfo = BasisSerialization.DeserializeValue(fileData); + if (discInfo?.StoredRemote?.RemoteBeeFileLocation != metaUrl) + { + continue; + } - if (!string.IsNullOrWhiteSpace(expectedRemoteUrl) && - !string.Equals(discInfo.StoredRemote.RemoteBeeFileLocation, expectedRemoteUrl, StringComparison.Ordinal)) - { - return false; - } + OnDiscData[GetDiscInfoKey(discInfo.StoredRemote.RemoteBeeFileLocation, discInfo.DownloadedPlatform)] = discInfo; + + if (!TryResolveStoredBeePath(discInfo, out _)) + { + continue; + } + + if (BasisIOManagement.CachePlatformMatchesCurrent(discInfo.DownloadedPlatform)) + { + info = discInfo; + return true; + } - OnDiscData[GetDiscInfoKey(discInfo.StoredRemote.RemoteBeeFileLocation, discInfo.DownloadedPlatform)] = discInfo; - if (!TryResolveStoredBeePath(discInfo, out _)) + if (string.IsNullOrWhiteSpace(discInfo.DownloadedPlatform) && legacyCandidate == null) + { + legacyCandidate = discInfo; + } + } + catch (Exception ex) { - return false; + BasisDebug.LogWarning($"Failed lazy-loading disc info from {file}: {ex.Message}", BasisDebug.LogTag.Event); } - - info = discInfo; - return true; } - catch (Exception ex) + + if (legacyCandidate != null) { - BasisDebug.LogWarning($"Failed loading disc info from {filePath}: {ex.Message}", BasisDebug.LogTag.Event); - return false; + info = legacyCandidate; + return true; } + + return false; } public static bool IsMetaDataOnDisc(string MetaURL, out BasisBEEExtensionMeta info) @@ -284,29 +301,14 @@ public static bool IsMetaDataOnDisc(string MetaURL, out BasisBEEExtensionMeta in return true; } - info = new BasisBEEExtensionMeta(); - return false; - } - } - - public static bool TryLoadMetaDataFromCacheFile(string uniqueVersion, string expectedRemoteUrl, out BasisBEEExtensionMeta info) - { - lock (_discInfoLock) - { - if (string.IsNullOrWhiteSpace(uniqueVersion)) - { - info = new BasisBEEExtensionMeta(); - return false; - } - - string platformAwarePath = BasisIOManagement.GetMetaCacheFilePath(uniqueVersion, BasisIOManagement.GetCurrentCachePlatform()); - if (TryLoadDiscInfoFromFile(platformAwarePath, expectedRemoteUrl, out info)) + if (TryLazyLoadDiscInfo(MetaURL, currentPlatform, out BasisBEEExtensionMeta lazyLoadedInfo)) { + info = lazyLoadedInfo; return true; } - string legacyPath = BasisIOManagement.GetLegacyMetaCacheFilePath(uniqueVersion); - return TryLoadDiscInfoFromFile(legacyPath, expectedRemoteUrl, out info); + info = new BasisBEEExtensionMeta(); + return false; } } @@ -368,16 +370,8 @@ public static async Task EnsureInitializationComplete() private static async Task LoadAllDiscData() { BasisDebug.Log("Loading all disc data...", BasisDebug.LogTag.Event); - string rootPath = BasisIOManagement.GenerateFolderPath(BasisBeeConstants.AssetBundlesFolder); - string currentPlatformPath = BasisIOManagement.GenerateFolderPath(Path.Combine(BasisBeeConstants.AssetBundlesFolder, BasisIOManagement.GetCurrentCachePlatform())); - - List files = new List(); - files.AddRange(Directory.GetFiles(rootPath, $"*{BasisBeeConstants.BasisMetaExtension}", SearchOption.TopDirectoryOnly)); - - if (!string.Equals(rootPath, currentPlatformPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(currentPlatformPath)) - { - files.AddRange(Directory.GetFiles(currentPlatformPath, $"*{BasisBeeConstants.BasisMetaExtension}", SearchOption.TopDirectoryOnly)); - } + string path = BasisIOManagement.GenerateFolderPath(BasisBeeConstants.AssetBundlesFolder); + string[] files = Directory.GetFiles(path, $"*{BasisBeeConstants.BasisMetaExtension}"); List loadTasks = new List(); @@ -412,4 +406,4 @@ private static void CleanupFiles(BasisStoredEncryptedBundle bundle) File.Delete(bundle.DownloadedBeeFileLocation); } } -} \ No newline at end of file +}