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