diff --git a/.github/docker/headless/linux/Dockerfile b/.github/docker/headless/linux/Dockerfile index 6df700d2e..2b9a0cd1b 100644 --- a/.github/docker/headless/linux/Dockerfile +++ b/.github/docker/headless/linux/Dockerfile @@ -6,11 +6,13 @@ RUN apt-get update && \ libglib2.0-0 \ libgtk-3-0 \ libx11-6 \ + curl \ && rm -rf /var/lib/apt/lists/* 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 259b63dfb..b9f4b64a7 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 a6dd4ab8b..8c7ab0424 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -231,6 +231,15 @@ jobs: default_password server1.basisvr.org 4296 + + + false + 127.0.0.1 + 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 000000000..5a575967b --- /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 000000000..71a74bf47 --- /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 fccfb9e07..f620cb27d 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,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; @@ -7,6 +10,8 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using UnityEngine; @@ -16,6 +21,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. @@ -29,6 +35,23 @@ 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"; + 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; + private bool configuredAvatarApplied; /// /// Scene change hook used in headless to aggressively strip visuals and free memory. @@ -54,7 +77,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 +94,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 +131,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 +142,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 +150,98 @@ 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; + 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; - } + string envAvatarFileLocation = ReadEnvironmentString("AvatarFileLocation"); + string envAvatarPassword = ReadEnvironmentString("AvatarPassword"); + 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, + defaultAvatarFileLocation, + defaultAvatarPassword, + 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); + 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); + 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)); + NormalizeConfiguredAvatarFields(); } - private static void TryCreateDefaultConfigXml(string filePath, string password, string ip, int port) + private static void TryCreateDefaultConfigXml( + string filePath, + string password, + string ip, + int port, + string avatarFileLocation, + string avatarPassword, + 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("AvatarFileLocation", avatarFileLocation ?? string.Empty), + new XElement("AvatarPassword", avatarPassword ?? string.Empty), + 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 +262,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 +301,63 @@ 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; + } + + 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 (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(); } /// @@ -229,6 +366,9 @@ public async void ConnectToNetwork() /// public async Task CreateAssetBundle() { + BasisDebug.Log("Skipping visual scene asset initialization on dedicated server build.", BasisDebug.LogTag.Networking); + await Task.CompletedTask; + return; if (BundledContentHolder.Instance.UseSceneProvidedHere) { BasisDebug.Log("using Local Asset Bundle or Addressable", BasisDebug.LogTag.Networking); @@ -246,10 +386,22 @@ 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; + if (BasisLocalPlayer.PlayerReady && BasisLocalPlayer.Instance != null) { EnsureHeadlessInput(); + _ = ApplyConfiguredAvatarAsync(); } else { @@ -258,11 +410,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,30 +422,35 @@ public override void StartSDK() BasisNetworkManagement.OnEnableInstanceCreate += ConnectToNetwork; } - // Strip visuals on scene switches 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; + BasisNetworkManagement.OnEnableInstanceCreate -= ConnectToNetwork; + SceneManager.activeSceneChanged -= OnSceneLoadeded; + CancelReconnectLoop(); + StopHealthEndpoint(); + BasisHeadlessRuntimeStatus.MarkStopping(); BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; -#endif } -#if UNITY_SERVER private void OnLocalPlayerReadyForHeadless() { BasisLocalPlayer.OnLocalPlayerInitalized -= OnLocalPlayerReadyForHeadless; EnsureHeadlessInput(); + _ = ApplyConfiguredAvatarAsync(); } private void EnsureHeadlessInput() { if (BasisHeadlessInput != null) { + BasisHeadlessInput.StopMovement(); return; } @@ -310,13 +465,312 @@ private void EnsureHeadlessInput() BasisHeadlessInput = gameObject.AddComponent(); BasisHeadlessInput.Initialize("Desktop Eye", nameof(Basis.Scripts.Device_Management.Devices.Headless.BasisHeadlessInput)); + BasisHeadlessInput.StopMovement(); BasisDeviceManagement.Instance.TryAdd(BasisHeadlessInput); } -#endif + + private void ApplyRuntimeConfiguration() + { + BasisHeadlessRuntimeStatus.ApplyConfiguration( + Ip, + Port, + HealthCheckEnabled, + HealthCheckHost, + HealthCheckPort, + HealthPath, + ReconnectEnabled, + ReconnectDelaySeconds, + MaxReconnectAttempts); + } + + private async Task ApplyConfiguredAvatarAsync() + { + if (configuredAvatarApplied || BasisLocalPlayer.Instance == null) + { + return; + } + + if (!TryResolveHeadlessAvatarSelection(out string avatarLocation, out byte avatarLoadMode, out string avatarPassword, out string avatarSource)) + { + configuredAvatarApplied = true; + return; + } + + configuredAvatarApplied = true; + + BasisLoadableBundle bundle = new BasisLoadableBundle + { + UnlockPassword = avatarPassword ?? string.Empty, + BasisRemoteBundleEncrypted = new BasisRemoteEncyptedBundle + { + RemoteBeeFileLocation = avatarLocation + }, + BasisLocalEncryptedBundle = new BasisStoredEncryptedBundle() + }; + + try + { + 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 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() + { + 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; + } /// public override void StopSDK() { + isShuttingDown = true; + BasisNetworkConnection.HeadlessReconnectSuppressed = true; + CancelReconnectLoop(); + StopHealthEndpoint(); + BasisHeadlessRuntimeStatus.MarkStopping(); BasisDebug.Log(nameof(StopSDK), BasisDebug.LogTag.Device); } @@ -330,14 +784,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 +824,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 000000000..d05115fe0 --- /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 000000000..c0b67df2d --- /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 2834e88f2..190e8f7e8 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs @@ -25,6 +25,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); @@ -141,12 +145,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; @@ -194,8 +204,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(); }); } diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkLifeCycle.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkLifeCycle.cs index e957a3edb..5a57f7deb 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(); } }