diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml
new file mode 100644
index 00000000..250a92d4
--- /dev/null
+++ b/.github/workflows/headless-portability.yml
@@ -0,0 +1,97 @@
+name: Headless portability
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/headless-portability.yml"
+ - "AcDream.slnx"
+ - "src/AcDream.Core/**"
+ - "src/AcDream.Core.Net/**"
+ - "src/AcDream.Content/**"
+ - "src/AcDream.Plugin.Abstractions/**"
+ - "src/AcDream.Runtime/**"
+ - "src/AcDream.Headless/**"
+ - "tests/AcDream.Core.Tests/**"
+ - "tests/AcDream.Core.Net.Tests/**"
+ - "tests/AcDream.Content.Tests/**"
+ - "tests/AcDream.Runtime.Tests/**"
+ - "tests/AcDream.Headless.Tests/**"
+ push:
+ paths:
+ - ".github/workflows/headless-portability.yml"
+ - "AcDream.slnx"
+ - "src/AcDream.Core/**"
+ - "src/AcDream.Core.Net/**"
+ - "src/AcDream.Content/**"
+ - "src/AcDream.Plugin.Abstractions/**"
+ - "src/AcDream.Runtime/**"
+ - "src/AcDream.Headless/**"
+ - "tests/AcDream.Core.Tests/**"
+ - "tests/AcDream.Core.Net.Tests/**"
+ - "tests/AcDream.Content.Tests/**"
+ - "tests/AcDream.Runtime.Tests/**"
+ - "tests/AcDream.Headless.Tests/**"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ portable-headless:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, ubuntu-latest]
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Install .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Build presentation-free closure
+ shell: pwsh
+ run: |
+ $projects = @(
+ "src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
+ "src/AcDream.Core/AcDream.Core.csproj",
+ "src/AcDream.Core.Net/AcDream.Core.Net.csproj",
+ "src/AcDream.Content/AcDream.Content.csproj",
+ "src/AcDream.Runtime/AcDream.Runtime.csproj",
+ "src/AcDream.Headless/AcDream.Headless.csproj"
+ )
+ foreach ($project in $projects) {
+ dotnet build $project -c Release
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
+
+ # AcDream.Core.Tests still contains historical App integration fixtures,
+ # so it is deliberately not a member of this no-App restore lane. Core is
+ # built directly above and exercised through every portable downstream
+ # test project below; the complete solution lane retains Core.Tests.
+ - name: Test presentation-free closure
+ shell: pwsh
+ run: |
+ $projects = @(
+ "tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
+ "tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
+ "tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj",
+ "tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj"
+ )
+ foreach ($project in $projects) {
+ dotnet test $project -c Release
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
+
+ - name: Verify CLI without connecting
+ shell: pwsh
+ run: |
+ dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- --help
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}'
+ dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
diff --git a/AcDream.slnx b/AcDream.slnx
index 5fc96499..b9ed96d4 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -6,6 +6,7 @@
+
@@ -23,6 +24,7 @@
+
diff --git a/src/AcDream.Headless/AcDream.Headless.csproj b/src/AcDream.Headless/AcDream.Headless.csproj
new file mode 100644
index 00000000..accb8038
--- /dev/null
+++ b/src/AcDream.Headless/AcDream.Headless.csproj
@@ -0,0 +1,22 @@
+
+
+ Exe
+ acdream-headless
+ net10.0
+ enable
+ enable
+ latest
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
new file mode 100644
index 00000000..0d5858b9
--- /dev/null
+++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace AcDream.Headless.Configuration;
+
+internal sealed class HeadlessConfiguration
+{
+ [JsonRequired]
+ public int Version { get; init; }
+
+ [JsonRequired]
+ public List Sessions { get; init; } = [];
+}
+
+internal sealed class HeadlessSessionDescriptor
+{
+ [JsonRequired]
+ public string Id { get; init; } = string.Empty;
+}
diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationException.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationException.cs
new file mode 100644
index 00000000..f257b1dc
--- /dev/null
+++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationException.cs
@@ -0,0 +1,9 @@
+namespace AcDream.Headless.Configuration;
+
+internal sealed class HeadlessConfigurationException : Exception
+{
+ internal HeadlessConfigurationException(string message)
+ : base(message)
+ {
+ }
+}
diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs
new file mode 100644
index 00000000..0141712e
--- /dev/null
+++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AcDream.Headless.Configuration;
+
+internal static class HeadlessConfigurationLoader
+{
+ private const int CurrentVersion = 1;
+
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ AllowTrailingCommas = false,
+ PropertyNameCaseInsensitive = false,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ ReadCommentHandling = JsonCommentHandling.Disallow,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ };
+
+ internal static HeadlessConfiguration Load(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ string fullPath = Path.GetFullPath(path);
+ using FileStream stream = File.OpenRead(fullPath);
+ HeadlessConfiguration? configuration =
+ JsonSerializer.Deserialize(
+ stream,
+ Options);
+
+ if (configuration is null)
+ {
+ throw new HeadlessConfigurationException(
+ "The configuration document is empty.");
+ }
+
+ if (configuration.Version != CurrentVersion)
+ {
+ throw new HeadlessConfigurationException(
+ $"Unsupported configuration version {configuration.Version}; "
+ + $"expected {CurrentVersion}.");
+ }
+
+ if (configuration.Sessions is null)
+ {
+ throw new HeadlessConfigurationException(
+ "sessions must be an array.");
+ }
+
+ var sessionIds = new HashSet(StringComparer.Ordinal);
+ foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
+ {
+ if (session is null
+ || string.IsNullOrWhiteSpace(session.Id))
+ {
+ throw new HeadlessConfigurationException(
+ "Every session requires a non-empty id.");
+ }
+
+ if (!sessionIds.Add(session.Id))
+ {
+ throw new HeadlessConfigurationException(
+ $"Duplicate session id '{session.Id}'.");
+ }
+ }
+
+ return configuration;
+ }
+}
diff --git a/src/AcDream.Headless/HeadlessAssemblyMarker.cs b/src/AcDream.Headless/HeadlessAssemblyMarker.cs
new file mode 100644
index 00000000..2854b49d
--- /dev/null
+++ b/src/AcDream.Headless/HeadlessAssemblyMarker.cs
@@ -0,0 +1,6 @@
+namespace AcDream.Headless;
+
+///
+/// Identifies the presentation-free production host assembly.
+///
+internal static class HeadlessAssemblyMarker;
diff --git a/src/AcDream.Headless/HeadlessEntryPoint.cs b/src/AcDream.Headless/HeadlessEntryPoint.cs
new file mode 100644
index 00000000..5f3135b2
--- /dev/null
+++ b/src/AcDream.Headless/HeadlessEntryPoint.cs
@@ -0,0 +1,82 @@
+using AcDream.Headless.Configuration;
+
+namespace AcDream.Headless;
+
+internal static class HeadlessEntryPoint
+{
+ private const string HelpText =
+ """
+ acdream-headless
+
+ Usage:
+ acdream-headless --help
+ acdream-headless validate --config
+
+ Commands:
+ validate Validate a versioned headless configuration without connecting.
+
+ Secrets are never accepted on the command line.
+ """;
+
+ internal static int Run(
+ IReadOnlyList arguments,
+ TextWriter output,
+ TextWriter error)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(error);
+
+ if (arguments.Count == 0
+ || IsHelp(arguments[0]))
+ {
+ output.WriteLine(HelpText);
+ return (int)HeadlessExitCode.Success;
+ }
+
+ if (!string.Equals(
+ arguments[0],
+ "validate",
+ StringComparison.Ordinal))
+ {
+ error.WriteLine("Unknown command. Run --help for usage.");
+ return (int)HeadlessExitCode.UsageError;
+ }
+
+ if (arguments.Count != 3
+ || !string.Equals(
+ arguments[1],
+ "--config",
+ StringComparison.Ordinal))
+ {
+ error.WriteLine(
+ "validate requires exactly: --config ");
+ return (int)HeadlessExitCode.UsageError;
+ }
+
+ try
+ {
+ HeadlessConfiguration configuration =
+ HeadlessConfigurationLoader.Load(arguments[2]);
+ output.WriteLine(
+ $"Configuration valid: version {configuration.Version}, "
+ + $"{configuration.Sessions.Count} session(s).");
+ return (int)HeadlessExitCode.Success;
+ }
+ catch (Exception exception)
+ when (exception is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Text.Json.JsonException
+ or HeadlessConfigurationException)
+ {
+ error.WriteLine($"Configuration invalid: {exception.Message}");
+ return (int)HeadlessExitCode.ConfigurationError;
+ }
+ }
+
+ private static bool IsHelp(string argument) =>
+ string.Equals(argument, "--help", StringComparison.Ordinal)
+ || string.Equals(argument, "-h", StringComparison.Ordinal);
+}
diff --git a/src/AcDream.Headless/HeadlessExitCode.cs b/src/AcDream.Headless/HeadlessExitCode.cs
new file mode 100644
index 00000000..0e199e8f
--- /dev/null
+++ b/src/AcDream.Headless/HeadlessExitCode.cs
@@ -0,0 +1,8 @@
+namespace AcDream.Headless;
+
+internal enum HeadlessExitCode
+{
+ Success = 0,
+ UsageError = 2,
+ ConfigurationError = 3,
+}
diff --git a/src/AcDream.Headless/Program.cs b/src/AcDream.Headless/Program.cs
new file mode 100644
index 00000000..2ceddadf
--- /dev/null
+++ b/src/AcDream.Headless/Program.cs
@@ -0,0 +1,3 @@
+using AcDream.Headless;
+
+return HeadlessEntryPoint.Run(args, Console.Out, Console.Error);
diff --git a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj
new file mode 100644
index 00000000..8a17a145
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ latest
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs b/tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs
new file mode 100644
index 00000000..c71de634
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs
@@ -0,0 +1,134 @@
+using System.Text.Json;
+using System.Xml.Linq;
+using System.Runtime.CompilerServices;
+
+namespace AcDream.Headless.Tests;
+
+public sealed class HeadlessDependencyBoundaryTests
+{
+ private static readonly string[] ForbiddenDependencyPrefixes =
+ [
+ "AcDream.App",
+ "AcDream.UI.",
+ "Silk.NET",
+ "OpenAL",
+ "Arch",
+ "ImGui",
+ ];
+
+ [Fact]
+ public void HeadlessAssemblyReferencesOnlyTheRuntimeProject()
+ {
+ string repositoryRoot = FindRepositoryRoot();
+ string projectPath = Path.Combine(
+ repositoryRoot,
+ "src",
+ "AcDream.Headless",
+ "AcDream.Headless.csproj");
+ var project = XDocument.Load(projectPath);
+ string projectDirectory = Path.GetDirectoryName(projectPath)!;
+
+ string[] actual = project
+ .Descendants("ProjectReference")
+ .Select(reference => reference.Attribute("Include")?.Value)
+ .Where(static include => !string.IsNullOrWhiteSpace(include))
+ .Select(include => Path.GetFullPath(Path.Combine(
+ projectDirectory,
+ include!.Replace(
+ '\\',
+ Path.DirectorySeparatorChar))))
+ .ToArray();
+ string expected = Path.Combine(
+ repositoryRoot,
+ "src",
+ "AcDream.Runtime",
+ "AcDream.Runtime.csproj");
+
+ Assert.Equal([expected], actual);
+ Assert.Empty(project.Descendants("PackageReference"));
+ }
+
+ [Fact]
+ public void HeadlessDependencyClosureHasNoPresentationOrBackendLibraries()
+ {
+ string depsPath = Path.ChangeExtension(
+ typeof(HeadlessDependencyBoundaryTests).Assembly.Location,
+ ".deps.json");
+
+ using JsonDocument document =
+ JsonDocument.Parse(File.ReadAllText(depsPath));
+ string[] libraries = document.RootElement
+ .GetProperty("libraries")
+ .EnumerateObject()
+ .Select(static library => LibraryName(library.Name))
+ .ToArray();
+
+ Assert.DoesNotContain(libraries, IsForbidden);
+ }
+
+ [Fact]
+ public void HelpPathLoadsNoPresentationOrBackendAssembly()
+ {
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ Assert.Equal(
+ 0,
+ HeadlessEntryPoint.Run(["--help"], output, error));
+
+ string[] loaded = AppDomain.CurrentDomain
+ .GetAssemblies()
+ .Select(static assembly =>
+ assembly.GetName().Name ?? string.Empty)
+ .ToArray();
+
+ Assert.DoesNotContain(loaded, IsForbidden);
+ }
+
+ private static string FindRepositoryRoot(
+ [CallerFilePath] string sourcePath = "")
+ {
+ string[] starts =
+ {
+ Path.GetDirectoryName(sourcePath) ?? string.Empty,
+ Directory.GetCurrentDirectory(),
+ AppContext.BaseDirectory,
+ };
+ foreach (string start in starts)
+ {
+ if (string.IsNullOrEmpty(start))
+ {
+ continue;
+ }
+
+ DirectoryInfo? directory = new(start);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(
+ directory.FullName,
+ "AcDream.slnx")))
+ {
+ return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
+ }
+
+ throw new DirectoryNotFoundException(
+ "Could not find AcDream.slnx above the working or output directory.");
+ }
+
+ private static string LibraryName(string libraryIdentity)
+ {
+ int separatorIndex = libraryIdentity.IndexOf('/');
+ return separatorIndex < 0
+ ? libraryIdentity
+ : libraryIdentity[..separatorIndex];
+ }
+
+ private static bool IsForbidden(string assemblyName) =>
+ ForbiddenDependencyPrefixes.Any(prefix =>
+ assemblyName.StartsWith(
+ prefix,
+ StringComparison.OrdinalIgnoreCase));
+}
diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
new file mode 100644
index 00000000..010da026
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
@@ -0,0 +1,109 @@
+namespace AcDream.Headless.Tests;
+
+public sealed class HeadlessEntryPointTests
+{
+ [Theory]
+ [InlineData("--help")]
+ [InlineData("-h")]
+ public void HelpIsPresentationFreeAndSuccessful(string argument)
+ {
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = HeadlessEntryPoint.Run(
+ [argument],
+ output,
+ error);
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("acdream-headless", output.ToString());
+ Assert.Contains("Secrets are never accepted", output.ToString());
+ Assert.Equal(string.Empty, error.ToString());
+ }
+
+ [Fact]
+ public void ValidateAcceptsStrictEmptyNoConnectConfiguration()
+ {
+ using var file = TemporaryConfiguration.Create(
+ """{"version":1,"sessions":[]}""");
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = HeadlessEntryPoint.Run(
+ ["validate", "--config", file.Path],
+ output,
+ error);
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("0 session(s)", output.ToString());
+ Assert.Equal(string.Empty, error.ToString());
+ }
+
+ [Theory]
+ [InlineData("""{"version":2,"sessions":[]}""", "Unsupported configuration version")]
+ [InlineData("""{"version":1,"sessions":[],"password":"secret"}""", "could not be mapped")]
+ [InlineData("""{"version":1,"sessions":[{"id":"bot"},{"id":"bot"}]}""", "Duplicate session id")]
+ [InlineData("""{"version":1,"sessions":[null]}""", "non-empty id")]
+ [InlineData("""{"version":1}""", "required properties")]
+ public void ValidateRejectsInvalidOrSecretShapedConfiguration(
+ string json,
+ string expectedError)
+ {
+ using var file = TemporaryConfiguration.Create(json);
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = HeadlessEntryPoint.Run(
+ ["validate", "--config", file.Path],
+ output,
+ error);
+
+ Assert.Equal(3, exitCode);
+ Assert.Contains(
+ expectedError,
+ error.ToString(),
+ StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("secret", error.ToString());
+ Assert.Equal(string.Empty, output.ToString());
+ }
+
+ [Fact]
+ public void UnknownCommandReturnsUsageError()
+ {
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = HeadlessEntryPoint.Run(
+ ["connect", "--password", "secret"],
+ output,
+ error);
+
+ Assert.Equal(2, exitCode);
+ Assert.DoesNotContain("secret", error.ToString());
+ Assert.Equal(string.Empty, output.ToString());
+ }
+
+ private sealed class TemporaryConfiguration : IDisposable
+ {
+ private TemporaryConfiguration(string path)
+ {
+ Path = path;
+ }
+
+ internal string Path { get; }
+
+ internal static TemporaryConfiguration Create(string json)
+ {
+ string path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ $"acdream-headless-{Guid.NewGuid():N}.json");
+ File.WriteAllText(path, json);
+ return new TemporaryConfiguration(path);
+ }
+
+ public void Dispose()
+ {
+ File.Delete(Path);
+ }
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs b/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs
index 949dc51d..262eb607 100644
--- a/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs
+++ b/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Xml.Linq;
+using System.Runtime.CompilerServices;
namespace AcDream.Runtime.Tests;
@@ -59,7 +60,11 @@ public sealed class RuntimeDependencyBoundaryTests
.Descendants("ProjectReference")
.Select(reference => reference.Attribute("Include")?.Value)
.Where(static include => !string.IsNullOrWhiteSpace(include))
- .Select(include => Path.GetFullPath(Path.Combine(projectDirectory, include!)))
+ .Select(include => Path.GetFullPath(Path.Combine(
+ projectDirectory,
+ include!.Replace(
+ '\\',
+ Path.DirectorySeparatorChar))))
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
var expectedReferences = new[]
@@ -94,21 +99,38 @@ public sealed class RuntimeDependencyBoundaryTests
Assert.DoesNotContain(loadedAssemblies, IsForbidden);
}
- private static string FindRepositoryRoot()
+ private static string FindRepositoryRoot(
+ [CallerFilePath] string sourcePath = "")
{
- var directory = new DirectoryInfo(AppContext.BaseDirectory);
- while (directory is not null)
+ string[] starts =
{
- if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
+ Path.GetDirectoryName(sourcePath) ?? string.Empty,
+ Directory.GetCurrentDirectory(),
+ AppContext.BaseDirectory,
+ };
+ foreach (string start in starts)
+ {
+ if (string.IsNullOrEmpty(start))
{
- return directory.FullName;
+ continue;
}
- directory = directory.Parent;
+ var directory = new DirectoryInfo(start);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(
+ directory.FullName,
+ "AcDream.slnx")))
+ {
+ return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
}
throw new DirectoryNotFoundException(
- $"Could not find AcDream.slnx above {AppContext.BaseDirectory}.");
+ "Could not find AcDream.slnx above the source, working, or output directory.");
}
private static string LibraryName(string libraryIdentity)