diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10e0ee6a..a3b86f38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: branches: - 'main' - 'series/*' + - 'feature/dll-validation' env: DOTNET_VERSION: '10.x' @@ -56,6 +57,21 @@ jobs: - name: Run Scanner Configuration Tests run: dotnet test -c ${{ matrix.configuration }} .\UnchainedLauncher.PakScannerApp.Tests --logger:"console;verbosity=detailed" + - name: Run CLI Tests + run: dotnet test -c ${{ matrix.configuration }} .\UnchainedLauncher.CLI.Tests --logger:"console;verbosity=detailed" + + - name: Run Plugin Manager Tests + run: dotnet test -c ${{ matrix.configuration }} .\UnchainedLauncher.GUI.Tests.Unit --logger:"console;verbosity=detailed" + + - name: Package CLI binaries + run: dotnet publish UnchainedLauncher.CLI -c ${{ matrix.configuration }} -r win-x64 --self-contained true -o out-cli/ + + - name: Upload CLI Artifacts + uses: actions/upload-artifact@v4 + with: + name: UnchainedLauncher_CLI_${{ matrix.configuration }}_${{ github.sha }} + path: out-cli + - name: Package ${{ matrix.configuration }} binaries run: dotnet publish UnchainedLauncher.GUI -c ${{ matrix.configuration}} -o out/ diff --git a/README.md b/README.md index 4e3395ba..731433a4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ Join the [Chivalry 2 Unchained community on Discord](https://discord.gg/chiv2unc 4. Put the downloaded Unchained `Chivalry2Launcher.exe` in place of the old one. 5. Launch the game normally (either from your steam library or from Epic Games Store). +## Launcher CLI + +`UnchainedLauncher.CLI` launches clients or servers and depends on Core, not WPF. +See [the CLI guide](UnchainedLauncher.CLI/README.md) for building, launch options, +registry formats, YAML configuration, and the live server dashboard. + ## License This project is licensed under the terms of the [LICENSE](LICENSE) file. diff --git a/StructuredINI.Tests/BufferedSectionMergeTests.cs b/StructuredINI.Tests/BufferedSectionMergeTests.cs new file mode 100644 index 00000000..76c92a48 --- /dev/null +++ b/StructuredINI.Tests/BufferedSectionMergeTests.cs @@ -0,0 +1,27 @@ +namespace StructuredINI.Tests; + +public sealed class BufferedSectionMergeTests { + [Fact] + public void MergeReplacesSelectedKeysButBufferSectionStillReplacesTheSection() { + var path = Path.Combine(Path.GetTempPath(), "ini-merge-" + Guid.NewGuid().ToString("N") + ".ini"); + try { + var writer = new StructuredINIWriter(); + writer.BufferSection("Section", [new("Default", "yes"), new(".Array", "old"), new("Scalar", "old")]); + writer.MergeSection("section", [new("!Array", "ClearArray"), new(".Array", "A"), new(".Array", "A"), new("Scalar", "new")]); + Assert.True(writer.WriteOut(path)); + var text = File.ReadAllText(path); + Assert.Contains("Default=yes", text); + Assert.DoesNotContain("old", text); + Assert.Contains("Scalar=new", text); + Assert.Equal(2, text.Split('\n').Count(line => line.TrimEnd('\r') == ".Array=A")); + File.Delete(path); + writer.BufferSection("SECTION", [new("Only", "replacement")]); + Assert.True(writer.WriteOut(path)); + text = File.ReadAllText(path); + Assert.Contains("Only=replacement", text); + Assert.DoesNotContain("Default", text); + Assert.DoesNotContain("Array", text); + } + finally { File.Delete(path); } + } +} \ No newline at end of file diff --git a/StructuredINI/StructuredINIWriter.cs b/StructuredINI/StructuredINIWriter.cs index 2ff74688..90c73f69 100644 --- a/StructuredINI/StructuredINIWriter.cs +++ b/StructuredINI/StructuredINIWriter.cs @@ -8,6 +8,26 @@ public class StructuredINIWriter { private readonly List _sectionOrder = new(); private readonly Dictionary _bufferedSections = new(StringComparer.OrdinalIgnoreCase); + // Buffer only caller-selected keys; WriteOut preserves all other keys. + public void BufferSection(string sectionName, IEnumerable> entries) { + if (!_bufferedSections.ContainsKey(sectionName)) _sectionOrder.Add(sectionName); + _bufferedSections[sectionName] = $"[{sectionName}]\n" + string.Join("\n", entries.Select(entry => $"{entry.Key}={entry.Value}")); + } + + // Merge caller-selected keys into a pending section; later values replace only those keys. + public void MergeSection(string sectionName, IEnumerable> entries) { + var lines = entries.Select(entry => $"{entry.Key}={entry.Value}").ToList(); + if (!_bufferedSections.TryGetValue(sectionName, out var buffered)) { + _sectionOrder.Add(sectionName); + _bufferedSections[sectionName] = $"[{sectionName}]\n" + string.Join("\n", lines); + return; + } + + var section = new IniSection(sectionName, SplitLines(buffered).Skip(1).ToList()); + PatchSection(section, ExtractKeysFromLines(lines), lines); + _bufferedSections[sectionName] = $"[{sectionName}]\n" + string.Join("\n", section.BodyLines); + } + public static bool Save(string path, T t) { var writer = new StructuredINIWriter(); return writer.BufferWrite(t) && writer.WriteOut(path); diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs new file mode 100644 index 00000000..fd46dee8 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -0,0 +1,163 @@ +using System.Diagnostics; +using System.Text.Json; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using Xunit; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class ArgumentBoundaryTests : IDisposable { + private readonly string _directory = Path.Combine( + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "argument-tests-" + Guid.NewGuid().ToString("N")); + private static string Probe => Path.Combine(AppContext.BaseDirectory, "argument-probe", + OperatingSystem.IsWindows() ? "ArgumentProbe.exe" : "ArgumentProbe"); + + public ArgumentBoundaryTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + public static IEnumerable ForwardedArguments() { + string[] arguments = ["", "\"", "a\"b", "a\\\"b", "a\\\\\"b", "\\", "tail\\\\", "space tail\\", + "-UserDir=C:\\Server Profiles\\", "tab\tvalue", "line\nbreak", "line\r\nbreak", " \t\n ", + "--", "-AUTH_PASSWORD=secret \"quoted\"\\", "& echo injected; $(echo injected) | >file"]; + foreach (var command in new[] { "client", "server" }) + foreach (var yaml in new[] { false, true }) + foreach (var argument in arguments) + yield return [command, yaml, argument]; + } + + [Theory] + [MemberData(nameof(ForwardedArguments))] + public async Task ForwardingPreservesExactProcessArguments(string command, bool yaml, string argument) { + // Repeat the value so YAML exercises both shared and command-specific forwarding. + var options = Parse(command, yaml, [argument, argument, "sentinel-after-forwarded"]); + var expected = new List { "TBL" }; + if (command == "server") expected.Add("-server"); + if (command == "server") expected.AddRange(FirstRunArguments); + expected.AddRange([argument, argument, "sentinel-after-forwarded", "-saveddirsuffix=" + options.Shared.SavedDirSuffix, "-unchained"]); + if (command == "server") expected.AddRange(DefaultServerArguments()); + expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); + + // Independent .NET ArgumentList control: no local escaping or parsing is used to form expected argv. + Assert.Equal(expected, await CaptureControl(expected)); + Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { + const string value = "quoted \"name\"\\\tline\nend\\"; + var options = Assert.IsType(Parse("server", yaml, [], value)); + var expected = new List { "TBL", "-server" }; + expected.AddRange(FirstRunArguments); + expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); + expected.AddRange(DefaultServerArguments(value, value, value)); + // Password follows the headless/backend flags, before local IP and desync options. + expected.Insert(expected.IndexOf("--local-ip"), "ServerPassword=" + value); + expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); + Assert.Equal(expected, await CaptureControl(expected)); + Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); + } + + [Fact] + public async Task NativePakDirectoryDoesNotRequireAProcessArgument() { + var installation = Path.Combine(_directory, "Game With Spaces"); + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", installation, "--platform", "Steam", "--local-ip", "127.0.0.1"]); + var expected = new List { "TBL", "-server" }; + expected.AddRange(FirstRunArguments); + expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); + expected.AddRange(DefaultServerArguments()); + expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); + Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); + } + + [Theory] + [InlineData(" SomeStructuredThing(foo, bar, 10, baz, false) ")] + [InlineData("quoted \"value\" and trailing slash\\")] + [InlineData("line\nbreak\tand & | > shell text")] + public async Task RawIniOverridesPreserveExactContentsAsOneArgument(string value) { + var path = Path.Combine(_directory, "raw.yaml"); + File.WriteAllText(path, $"chiv2-path: {JsonSerializer.Serialize(_directory)}\nplatform: Steam\nserver:\n" + + " local-ip: 127.0.0.1\n ini:\n Game:\n /Script/TBL.TBLGameMode:\n" + + $" Custom: {{type: raw, value: {JsonSerializer.Serialize(value)}}}\n"); + var options = CliLaunchOptions.Parse(["server", "--config-path", path]); + var expected = new List { "TBL", "-server" }; + expected.AddRange(FirstRunArguments); + expected.Add("-ini:Game:[/Script/TBL.TBLGameMode]:Custom=" + value); + expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); + expected.AddRange(DefaultServerArguments()); + expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); + Assert.Equal(expected, await CaptureControl(expected)); + Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); + } + + private CliLaunchOptions Parse(string command, bool yaml, string[] arguments, string? serverValue = null) { + if (!yaml) return CliLaunchOptions.Parse([command, "--chivalry2-path", _directory, "--platform", "Steam", + .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [], + .. serverValue is null ? System.Array.Empty() : ["--name", serverValue, "--description", serverValue, + "--starting-map", serverValue, "--password", serverValue], "--", .. arguments]); + var path = Path.Combine(_directory, "launch.yaml"); + // JSON-quoted strings are YAML scalars; cover both shared and command-specific game-args lists. + File.WriteAllText(path, $"chiv2-path: {JsonSerializer.Serialize(_directory)}\nplatform: Steam\n" + + $"game-args: {JsonSerializer.Serialize(arguments.Take(1))}\n{command}:\n" + + (command == "server" ? " local-ip: 127.0.0.1\n" : "") + + (serverValue is null ? "" : string.Concat(new[] { "name", "description", "starting-map", "password" } + .Select(key => $" {key}: {JsonSerializer.Serialize(serverValue)}\n"))) + + $" game-args: {JsonSerializer.Serialize(arguments.Skip(1))}\n"); + return CliLaunchOptions.Parse([command, "--config-path", path]); + } + + private static readonly string[] FirstRunArguments = [ + "-ini:Game:[/Script/TBL.TBLTitleScreen]:bSavedHasAgreedToTOS=True", + "-ini:Game:[/Script/TBL.TBLGameInstance]:FirstLoadCompleted=True" + ]; + + private static IEnumerable DefaultServerArguments(string name = "Unchained Server", + string description = "", string map = "FFA_Courtyard") => [ + "-ini:Game:[/Script/TBL.TBLGameMode]:ServerName=" + name, "--next-map-name", map, + "Port=7777", "GameServerPingPort=3075", "GameServerQueryPort=7071", "-rcon", "9001", + "--server-browser-description", description, "--motd", description, "--censor-mode", "none", + "-nullrhi", "-unattended", "-nosound", "--use-backend-banlist", "--local-ip", "127.0.0.1", "--desync-patch" + ]; + + private async Task CaptureLaunch(LaunchOptions options) { + var output = Path.Combine(_directory, "launch-result.json"); + Directory.CreateDirectory(Path.Combine(_directory, FilePaths.BinDir)); + var preparer = Chivalry2LaunchPreparer.Create(opts => Some(opts with { + LaunchArgs = ArgumentEscaper.Escape(output) + " " + opts.LaunchArgs + })); + // Use the actual Core launcher and ProcessLauncher (not a string-capturing mock). + var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(Probe), + _directory, new NoOpInjector()); + var result = await launcher.TryLaunch(options); + using var process = result.Match(Left: error => throw new InvalidOperationException(error.Message), Right: process => process); + return await ReadResult(process, output); + } + + private async Task CaptureControl(IEnumerable arguments) { + var output = Path.Combine(_directory, "control-result.json"); + var start = new ProcessStartInfo(Probe) { UseShellExecute = false }; + start.ArgumentList.Add(output); + foreach (var argument in arguments) start.ArgumentList.Add(argument); + using var process = Process.Start(start)!; + return await ReadResult(process, output); + } + + private static async Task ReadResult(Process process, string output) { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + try { await process.WaitForExitAsync(timeout.Token); } + catch { + if (!process.HasExited) process.Kill(entireProcessTree: true); + throw; + } + Assert.Equal(0, process.ExitCode); + return JsonSerializer.Deserialize(await File.ReadAllTextAsync(output))!; + } + + private sealed class NoOpInjector : IProcessInjector { + public bool Inject(Process process) => true; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore b/UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore new file mode 100644 index 00000000..4ded7c4c --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/obj/ diff --git a/UnchainedLauncher.CLI.Tests/ArgumentProbe/ArgumentProbe.csproj b/UnchainedLauncher.CLI.Tests/ArgumentProbe/ArgumentProbe.csproj new file mode 100644 index 00000000..238f157e --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ArgumentProbe/ArgumentProbe.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0 + enable + enable + + diff --git a/UnchainedLauncher.CLI.Tests/ArgumentProbe/Program.cs b/UnchainedLauncher.CLI.Tests/ArgumentProbe/Program.cs new file mode 100644 index 00000000..da36781c --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ArgumentProbe/Program.cs @@ -0,0 +1,4 @@ +using System.Text.Json; + +// The first argument is the result file; all remaining arguments come from the launcher. +File.WriteAllText(args[0], JsonSerializer.Serialize(args.Skip(1).ToArray())); \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs new file mode 100644 index 00000000..b4da308b --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs @@ -0,0 +1,70 @@ +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class CommandLineOptionsTests { + [Theory] + [InlineData("server")] + [InlineData("client")] + public void GeneratedHelpIsSpecificToTheCommand(string command) { + var options = CliLaunchOptions.Parse([command, "--help"]); + Assert.True(options.Help); + Assert.Contains("--chivalry2-path", options.HelpText); + Assert.Contains("--config-path", options.HelpText); + Assert.Equal(command == "server", options.HelpText.Contains("--no-desync-patch")); + Assert.Equal(command == "server", options.HelpText.Contains("--discord-bot-token")); + } + + [Fact] + public void RootHelpListsBothCommands() { + var options = CliLaunchOptions.Parse([]); + Assert.True(options.Help); + Assert.Contains("server", options.HelpText); + Assert.Contains("client", options.HelpText); + } + + [Theory] + [InlineData("--no-desync-patch")] + [InlineData("--name=test")] + [InlineData("--game-port=7778")] + [InlineData("--no-tui")] + public void ServerSpecificOptionsAreRejectedByClient(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse([ + "client", "--chivalry2-path", ".", "--platform", "Steam", flag + ])); + } + + [Theory] + [InlineData("--no-desync-patch")] + [InlineData("--no-plugin-update")] + [InlineData("--name=test")] + public void SingletonOptionsCannotBeRepeated(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse([ + "server", "--chivalry2-path", ".", "--platform", "Steam", flag, flag + ])); + } + + [Fact] + public void HelpAndInvalidCliAlsoUseOneLibraryParse() { + foreach (var args in new string[][] { [], ["client", "--help"], ["server", "--platform=Invalid"] }) { + var calls = 0; + CliCommandOptions Parse() => CommandLineOptions.Parse(args, (root, tokens, configuration) => { + calls++; + return root.Parse(tokens, configuration); + }); + if (args.Contains("--platform=Invalid")) Assert.Throws(Parse); + else Assert.True(Parse().Help); + Assert.Equal(1, calls); + } + } + + [Fact] + public void GameArgumentsMustFollowSeparatorAndArePreservedExactly() { + string[] prefix = ["server", "--chivalry2-path", ".", "--platform", "Steam"]; + Assert.Throws(() => CliLaunchOptions.Parse([.. prefix, "unexpected"])); + string[] forwarded = ["--help", "--config-path", "not-a-launcher-config", "-AUTH_PASSWORD=with spaces", "", "@game-file"]; + var options = CliLaunchOptions.Parse([.. prefix, "--", .. forwarded]); + Assert.False(options.Help); + Assert.Equal(forwarded, options.GameArguments); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs b/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs new file mode 100644 index 00000000..6dcf560d --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Reflection; +using System.Security.Cryptography; +using UnchainedLauncher.Core.Services.Dlls; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class DllCachePublicationTests { + [Theory] + [InlineData("valid")] + [InlineData("size")] + [InlineData("hash")] + [InlineData("directory")] + public async Task DownloadValidatesConcurrentCacheWinnerWithoutOverwritingIt(string winnerKind) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? + (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-publication-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + byte[] bytes = [1, 2, 3]; + var release = new DllRelease("org", "Plugin", "1.0.0", "https://example.test/Plugin.dll", + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), bytes.Length); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try { + using var http = new HttpClient(new PausedDownload(bytes, started, finish)); + var cacheRoot = Path.Combine(root, "cache"); + var plugins = Path.Combine(root, "Plugins"); + var manager = DllManager.CreateForLaunch(plugins, [release], [release], http, () => [], cacheRoot); + var preparation = manager.PrepareAsync(); + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var source = Path.Combine(root, "source.dll"); + File.WriteAllBytes(source, bytes); + var cache = Path.Combine(cacheRoot, release.Sha256, release.EffectiveFileName); + // Exercise the real portable publisher; native archival uses this publication boundary on Windows. + var copy = typeof(DllManager).GetMethod("CopyValidated", BindingFlags.NonPublic | BindingFlags.Static)!; + copy.Invoke(null, [source, cache, release, false, null, null, true]); + if (winnerKind == "size") File.WriteAllBytes(cache, [9]); + if (winnerKind == "hash") File.WriteAllBytes(cache, [9, 8, 7]); + if (winnerKind == "directory") { File.Delete(cache); Directory.CreateDirectory(cache); } + var winner = winnerKind == "directory" ? null : File.ReadAllBytes(cache); + finish.SetResult(); + if (winnerKind == "valid") { + await preparation.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(bytes, File.ReadAllBytes(Assert.Single(Directory.GetFiles(plugins, "*.dll", SearchOption.AllDirectories)))); + } + else { + if (winnerKind == "directory") { + var error = await Assert.ThrowsAnyAsync(() => preparation.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.True(error is IOException or UnauthorizedAccessException, error.ToString()); + } + else + await Assert.ThrowsAsync(() => preparation.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.False(Directory.Exists(plugins)); + } + if (winnerKind == "directory") Assert.True(Directory.Exists(cache)); + else Assert.Equal(winner, File.ReadAllBytes(cache)); + Assert.Equal(bytes, File.ReadAllBytes(source)); + Assert.Empty(Directory.GetFiles(root, "*.tmp", SearchOption.AllDirectories)); + } + finally { finish.TrySetResult(); Directory.Delete(root, true); } + } + + private sealed class PausedDownload(byte[] bytes, TaskCompletionSource started, TaskCompletionSource finish) : HttpMessageHandler { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + started.SetResult(); + await finish.Task.WaitAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request, Content = new ByteArrayContent(bytes) }; + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs new file mode 100644 index 00000000..5283e66c --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -0,0 +1,579 @@ +using LanguageExt; +using System.Security.Cryptography; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Utilities; +using Xunit; +using DllManager = UnchainedLauncher.Core.Services.Mods.DllLaunchSession; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class DllManagerTests { + private static DllRelease Release(string id, string version = "v1.0.0", string target = "shared", params Dependency[] dependencies) => + new(version, Convert.ToHexString(SHA256.HashData([1, 2, 3])).ToLowerInvariant(), "Plugin.dll", "https://example.test/plugin.dll", 3, + DateTimeOffset.UtcNow, new DllInfo { + RepoUrl = "https://github.com/" + id, + Name = id, + Description = "test", + Authors = ["test"], + Target = target, + PluginDependencies = dependencies.ToList() + }, null); + private static DllPackage Package(params DllRelease[] releases) => new(releases[0].Info, releases.ToList()); + + [Theory] + [InlineData(">=v1.0.0,<2.0.0")] + [InlineData("^v1.0.0")] + [InlineData(" >=v1.0.0 , =1.0.0,<2.0.0")] + [InlineData("^1.0.0")] + [InlineData("v1.0.0")] + [InlineData("==v1.0.0")] + [InlineData("!=v2.0.0")] + public async Task RegisteredLocalPackagesUseTheSameDependencyGrammarDuringSessionPreparation(string requirement) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), + "dll-registered-range-" + Guid.NewGuid().ToString("N")); + try { + var core = Release("Chiv2-Community/UnchainedPlugin", dependencies: [new Dependency("https://github.com/org/dependency", requirement)]) + with { DllFileName = "UnchainedPlugin.dll" }; + var dependency = Release("org/dependency") with { DllFileName = "dependency.dll" }; + var newer = dependency with { Tag = "v2.0.0" }; + foreach (var package in new[] { Package(core), Package(dependency, newer) }) { + var id = package.LatestReleaseInfo.Identifier; + var directory = Path.Combine(root, "registry", "dll_db", "packages", id.Org); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, id.ModuleName + ".json"), JsonHelpers.Serialize(package)); + } + var registry = new AggregateModRegistry(new LocalModRegistry(Path.Combine(root, "registry"))); + var catalog = await DllCatalog.ReadAsync(registry, CancellationToken.None); + Assert.Empty(catalog.Errors); + Assert.Equal(2, catalog.Packages.Count); + var resolved = DllManager.Resolve(catalog.Packages, [], false, false); + Assert.Equal(new[] { dependency.Info.RepoUrl, core.Info.RepoUrl }, resolved.Select(r => r.Info.RepoUrl)); + Assert.All(resolved, r => Assert.Equal("v1.0.0", r.Tag)); + var assets = new ProfileAssetPaths("Range", root); + foreach (var release in new[] { core, dependency }) { + var cache = Path.Combine(assets.DllCache, release.Hash, release.DllFileName); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + File.WriteAllBytes(cache, [1, 2, 3]); + } + var mods = new ModManager(registry, assets.CreatePakDir(root), []); + var session = new DllManager(registry, mods, assets.Plugins, cacheDirectory: assets.DllCache); + var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + Assert.True((await session.PrepareLaunch(options)).IsSome); + Assert.Equal(2, Directory.GetFiles(assets.Plugins, "*.dll", SearchOption.AllDirectories).Length); + foreach (var file in Directory.GetFiles(assets.Plugins, "*.dll", SearchOption.AllDirectories)) + Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(file)); + using var inventory = System.Text.Json.JsonDocument.Parse(File.ReadAllText(Path.Combine(assets.Plugins, "dll_manager.json"))); + Assert.Equal(new[] { dependency.Info.RepoUrl, core.Info.RepoUrl }, + inventory.RootElement.EnumerateArray().Select(item => item.GetProperty("Repository").GetString())); + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData("^vv1.0.0")] + [InlineData(">=v1.0,<2.0.0")] + [InlineData(">=v01.0.0")] + [InlineData(">=V1.0.0")] + [InlineData("=v1.0.0")] + [InlineData(">=v1.0.0,garbage")] + [InlineData(">=v1.0.0,")] + public async Task MalformedRegisteredRequirementsFailBeforeSessionMutatesPlugins(string requirement) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), + "dll-invalid-range-" + Guid.NewGuid().ToString("N")); + try { + var core = Release("Chiv2-Community/UnchainedPlugin", dependencies: [new Dependency("https://github.com/org/dependency", requirement)]); + var directory = Path.Combine(root, "registry", "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(core))); + var registry = new AggregateModRegistry(new LocalModRegistry(Path.Combine(root, "registry"))); + var catalog = await DllCatalog.ReadAsync(registry, CancellationToken.None); + Assert.NotEmpty(catalog.Errors); + var assets = new ProfileAssetPaths("Invalid", root); + var mods = new ModManager(registry, assets.CreatePakDir(root), []); + var session = new DllManager(registry, mods, assets.Plugins, cacheDirectory: assets.DllCache); + var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + var error = await Assert.ThrowsAsync(() => session.PrepareLaunch(options)); + Assert.Contains("DLL registry loading failed", error.Message); + Assert.False(Directory.Exists(assets.Plugins)); + Assert.False(Directory.Exists(assets.DllCache)); + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData("^1.0.0", true)] + [InlineData(">=v1.0.0,<2.0.0", true)] + [InlineData("==1.0.0", true)] + [InlineData("!=1.0.0", false)] + [InlineData("v1.0.0", true)] + [InlineData(">=2.0.0", false)] + public void RegistryVersionRequirementsAreEnforced(string requirement, bool matches) => + Assert.Equal(matches, DllManager.Satisfies(Release("org/test"), requirement)); + + [Fact] + public void DependenciesAreOrderedBeforeDependentsAndExplicitPinsAreHonored() { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var dependency = Release("org/dependency"); + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", "^1.0.0")]); + var newer = plugin with { Tag = "v2.0.0" }; + var resolved = DllManager.Resolve([Package(core), Package(dependency), Package(plugin, newer)], + [new ReleaseCoordinates("org", "plugin", "v1.0.0")], false, false); + Assert.Equal(new[] { core, dependency, plugin }, resolved); + } + + [Fact] + public void WrongTargetsMissingDependenciesAndCyclesFailClosed() { + var core = Release("Chiv2-Community/UnchainedPlugin", target: "server"); + Assert.Throws(() => DllManager.Resolve([Package(core)], [], false, false)); + core = core with { Info = core.Info with { Target = "shared", PluginDependencies = [new Dependency("https://github.com/org/missing", "1.0.0")] } }; + Assert.Throws(() => DllManager.Resolve([Package(core)], [], false, false)); + var dependency = Release("org/missing", dependencies: [new Dependency("https://github.com/Chiv2-Community/UnchainedPlugin", "1.0.0")]); + Assert.Throws(() => DllManager.Resolve([Package(core), Package(dependency)], [], false, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DependencyPinsAreHonoredInEitherRequestOrder(bool dependencyFirst) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var older = Release("org/dependency"); + var newer = older with { Tag = "v2.0.0" }; + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); + ReleaseCoordinates[] roots = [new("org", "plugin", plugin.Tag), new("ORG", "Dependency", older.Tag)]; + if (dependencyFirst) Array.Reverse(roots); + var resolved = DllManager.Resolve([Package(core), Package(older, newer), Package(plugin)], roots, false, false); + Assert.Equal(new[] { core, older, plugin }, resolved); + } + + [Theory] + [InlineData(false, "v2.0.0", "shared", ">=1.0.0", false, false)] + [InlineData(true, "v2.0.0", "shared", ">=1.0.0", false, false)] + [InlineData(false, "latest", "shared", ">=1.0.0", false, true)] + [InlineData(true, "latest", "shared", ">=1.0.0", false, true)] + [InlineData(false, "v1.0.0", "shared", ">=2.0.0", false, false)] + [InlineData(true, "v1.0.0", "shared", ">=2.0.0", false, false)] + [InlineData(false, "v1.0.0", "server", ">=1.0.0", false, false)] + [InlineData(true, "v1.0.0", "server", ">=1.0.0", false, false)] + [InlineData(false, "v1.0.0", "server", ">=1.0.0", true, true)] + [InlineData(true, "v1.0.0", "server", ">=1.0.0", true, true)] + public void IndexedPinsStillEnforceConflictsRequirementsAndTargets(bool reverse, string otherPin, + string target, string requirement, bool server, bool succeeds) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var older = Release("org/dependency", target: target); + var newer = older with { Tag = "v2.0.0", Info = older.Info with { Target = "shared" } }; + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", requirement)]); + ReleaseCoordinates[] roots = [new("org", "plugin", plugin.Tag), new("org", "dependency", older.Tag), new("org", "dependency", otherPin)]; + if (reverse) Array.Reverse(roots); + DllPackage[] packages = [Package(core), Package(older, newer), Package(plugin)]; + if (succeeds) Assert.Equal(new[] { core, older, plugin }, DllManager.Resolve(packages, roots, server, false)); + else Assert.Throws(() => DllManager.Resolve(packages, roots, server, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExplicitPrereleaseDependencyPinIsHonoredInEitherRequestOrder(bool reverse) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var dependency = Release("org/dependency", "v1.0.0-rc.1"); + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", "==1.0.0-rc.1")]); + ReleaseCoordinates[] roots = [new("org", "plugin", plugin.Tag), new("org", "dependency", dependency.Tag)]; + if (reverse) Array.Reverse(roots); + Assert.Equal(new[] { core, dependency, plugin }, DllManager.Resolve( + [Package(core), Package(dependency), Package(plugin)], roots, false, false)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void AutomaticDependencyPrereleasesStillRequirePermission(bool allowUnstable, bool requestLatest) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var dependency = Release("org/dependency", "v1.0.0-rc.1"); + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", "==1.0.0-rc.1")]); + List roots = [new("org", "plugin", plugin.Tag)]; + if (requestLatest) roots.Add(new("org", "dependency", "latest")); + DllPackage[] packages = [Package(core), Package(dependency), Package(plugin)]; + if (allowUnstable) Assert.Equal(new[] { core, dependency, plugin }, DllManager.Resolve(packages, roots, false, true)); + else Assert.Throws(() => DllManager.Resolve(packages, roots, false, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IndexedPinsDoNotBypassDependencyCycleDetection(bool reverse) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var dependency = Release("org/dependency", dependencies: [new Dependency("https://github.com/org/plugin", ">=1.0.0")]); + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); + ReleaseCoordinates[] roots = [new("org", "plugin", plugin.Tag), new("org", "dependency", dependency.Tag)]; + if (reverse) Array.Reverse(roots); + var error = Assert.Throws(() => DllManager.Resolve( + [Package(core), Package(dependency), Package(plugin)], roots, false, false)); + Assert.StartsWith("Cyclic DLL dependency:", error.Message); + } + + [Fact] + public void ConflictingPinsAndUnapprovedAutomaticPrereleasesAreRejected() { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var newer = core with { Tag = "v2.0.0" }; + Assert.Throws(() => DllManager.Resolve([Package(core, newer)], + [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", core.Tag), + new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", newer.Tag)], false, false)); + var prerelease = core with { Tag = "v3.0.0-rc.1" }; + Assert.Throws(() => DllManager.Resolve([Package(prerelease)], [], false, false)); + Assert.Equal(prerelease, Assert.Single(DllManager.Resolve([Package(prerelease)], [], false, true))); + Assert.Equal(prerelease, Assert.Single(DllManager.Resolve([Package(prerelease)], + [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", prerelease.Tag)], false, false))); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void SharedDependenciesUseAllRootRequirements(bool reverse, bool conflict) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var older = Release("org/dependency"); + var newer = older with { Tag = "v2.0.0" }; + var first = Release("org/first", dependencies: [new Dependency("https://github.com/org/dependency", conflict ? ">=2.0.0" : ">=1.0.0")]); + var second = Release("org/second", dependencies: [new Dependency("https://github.com/org/dependency", "<2.0.0")]); + ReleaseCoordinates[] roots = [new("org", "first", first.Tag), new("org", "second", second.Tag)]; + if (reverse) Array.Reverse(roots); + DllPackage[] packages = [Package(core), Package(older, newer), Package(first), Package(second)]; + if (conflict) { + var error = Assert.Throws(() => DllManager.Resolve(packages, roots, false, false)); + Assert.Equal("Conflicting DLL requirements: org/dependency", error.Message); + } + else Assert.Equal(reverse ? new[] { core, older, second, first } : new[] { core, older, first, second }, + DllManager.Resolve(packages, roots, false, false)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void ReselectionReplacesTransitiveRequirementsAndDropsAbandonedDependencies(bool reverse, bool constrainDependency) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var leaf = Release("org/leaf"); + var newerLeaf = leaf with { Tag = "v2.0.0" }; + var abandoned = Release("org/abandoned"); + var older = Release("org/dependency", dependencies: [new Dependency("https://github.com/org/leaf", "<2.0.0")]); + var newer = older with { + Tag = "v2.0.0", + Info = older.Info with { + PluginDependencies = [ + new Dependency("https://github.com/org/leaf", ">=2.0.0"), new Dependency("https://github.com/org/abandoned", ">=1.0.0") ] + } + }; + var first = Release("org/first", dependencies: [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); + var second = Release("org/second", dependencies: constrainDependency + ? [new Dependency("https://github.com/org/dependency", "<2.0.0"), new Dependency("https://github.com/org/leaf", "<2.0.0")] + : [new Dependency("https://github.com/org/leaf", "<2.0.0")]); + ReleaseCoordinates[] roots = [new("org", "first", first.Tag), new("org", "second", second.Tag)]; + if (reverse) Array.Reverse(roots); + var expected = !reverse ? new[] { core, leaf, older, first, second } + : constrainDependency ? new[] { core, leaf, older, second, first } : new[] { core, leaf, second, older, first }; + Assert.Equal(expected, + DllManager.Resolve([Package(core), Package(older, newer), Package(leaf, newerLeaf), Package(abandoned), Package(first), Package(second)], + roots, false, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LatestSharedDependencyUsesHighestCompatibleRelease(bool reverse) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var oldest = Release("org/dependency"); + var compatible = oldest with { Tag = "v1.5.0" }; + var newest = oldest with { Tag = "v2.0.0" }; + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", "<2.0.0")]); + ReleaseCoordinates[] roots = [new("org", "dependency", "latest"), new("org", "plugin", plugin.Tag)]; + if (reverse) Array.Reverse(roots); + Assert.Equal(new[] { core, compatible, plugin }, DllManager.Resolve( + [Package(core), Package(oldest, newest, compatible), Package(plugin)], roots, false, false)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void UnavailableDependenciesKeepSpecificErrors(bool missingPackage) { + var core = Release("Chiv2-Community/UnchainedPlugin", dependencies: [new Dependency("https://github.com/org/dependency", ">=2.0.0")]); + DllPackage[] packages = missingPackage ? [Package(core)] : [Package(core), Package(Release("org/dependency"))]; + var error = Assert.Throws(() => DllManager.Resolve(packages, [], false, false)); + Assert.Equal(missingPackage ? "DLL package not found: org/dependency" + : "DLL release not found for launch target: org/dependency:>=2.0.0", error.Message); + } + + [Theory] + [InlineData("../x.dll")] + [InlineData("..\\x.dll")] + [InlineData("C:x.dll")] + [InlineData("x?.dll")] + [InlineData("x.exe")] + public void UnsafeRegistryFileNamesAreRejected(string name) => + Assert.Throws(() => DllManager.SafeFileName(name)); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExistingFilesRequireRegistryHashAndSizeAndInventoryRecordsApprovedSelection(bool legacyCli) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-manager-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + var packagePath = Path.Combine(root, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); + var registry = new AggregateModRegistry(new LocalModRegistry(root)); + var mods = new ModManager(registry, new PakDir(root, []), []); + var plugins = Path.Combine(root, "Plugins"); + Directory.CreateDirectory(plugins); + var manager = new DllManager(registry, mods, plugins); + var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + var plugin = legacyCli + ? Path.Combine(plugins, ".dll-managed", release.Hash, "UnchainedPlugin.dll") + : Path.Combine(plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", release.Hash, "UnchainedPlugin.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(plugin)!); + await File.WriteAllBytesAsync(plugin, [1, 2, 3]); + if (!OperatingSystem.IsWindows()) { + await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); + Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(plugin)); + Assert.False(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + return; + } + Assert.True((await manager.PrepareLaunch(options)).IsSome); + plugin = Assert.Single(Directory.GetFiles(plugins, "*.dll", SearchOption.AllDirectories)); + Assert.Contains(release.Hash, await File.ReadAllTextAsync(Path.Combine(plugins, "dll_manager.json"))); + Assert.Equal(Path.Combine(plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", "UnchainedPlugin.dll"), plugin); + await File.WriteAllBytesAsync(plugin, [3, 2, 1]); + Assert.False(await DllManager.MatchesFileAsync(plugin, release)); + Assert.False(manager.Inject(new System.Diagnostics.Process())); + await File.WriteAllBytesAsync(Path.Combine(root, ".cache", "dlls", release.Hash.ToLowerInvariant(), release.DllFileName), [3, 2, 1]); + await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); + Assert.False(manager.Inject(new System.Diagnostics.Process())); + Assert.Equal(new byte[] { 3, 2, 1 }, await File.ReadAllBytesAsync(plugin)); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Theory] + [InlineData("client")] + [InlineData("server")] + public async Task DllPreparationUsesOnlyEachProfilePluginDirectory(string command) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), "dll-instances-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + var packagePath = Path.Combine(root, "registry", "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); + var registry = new AggregateModRegistry(new LocalModRegistry(Path.Combine(root, "registry"))); + var globalPlugins = Path.Combine(root, FilePaths.PluginDir); + Directory.CreateDirectory(globalPlugins); + var loose = Path.Combine(globalPlugins, "user.dll"); + await File.WriteAllBytesAsync(loose, [9, 9, 9]); + var paths = new List(); + using var http = new HttpClient(new DownloadHandler([1, 2, 3])); + foreach (var suffix in new[] { "One", "Two" }) { + var options = CliLaunchOptions.Parse([ + command, "--chivalry2-path", root, "--platform", "Steam", "--saved-dir-suffix", suffix]); + options.Shared.Assets = new ProfileAssetPaths(suffix, root); + var mods = ServerLauncher.CreateModManager(options, registry); + var session = ServerLauncher.CreateDllSession(options, registry, mods, client: http); + Assert.True((await session.PrepareLaunch(options.ToLaunchOptions())).IsSome); + var installed = Path.Combine(options.InstancePaths.Plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", "UnchainedPlugin.dll"); + Assert.True(await DllManager.MatchesFileAsync(installed, release)); + Assert.True(File.Exists(Path.Combine(options.InstancePaths.Plugins, "dll_manager.json"))); + paths.Add(installed); + } + Assert.NotEqual(paths[0], paths[1]); + Assert.All(paths, path => Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(path))); + Assert.Equal([loose], Directory.GetFiles(globalPlugins, "*", SearchOption.AllDirectories)); + Assert.Equal(new byte[] { 9, 9, 9 }, await File.ReadAllBytesAsync(loose)); + } + finally { Directory.Delete(root, true); } + } + + private sealed class DownloadHandler(byte[] bytes) : HttpMessageHandler { + public int Requests { get; private set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + Requests++; + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new ByteArrayContent(bytes) }); + } + } + + [Theory] + [InlineData("client")] + [InlineData("server")] + public async Task NoPluginUpdateStillSelectsLatestUnlessExplicitlyPinned(string command) { + var root = Path.Combine(Path.GetTempPath(), "dll-no-update-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var older = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + var newer = older with { Tag = "v2.0.0", Hash = Convert.ToHexString(SHA256.HashData(new byte[] { 4, 5, 6 })).ToLowerInvariant() }; + var packagePath = Path.Combine(root, "registry", "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(older, newer))); + var registry = new AggregateModRegistry(new LocalModRegistry(Path.Combine(root, "registry"))); + var mods = new ModManager(registry, new PakDir(Path.Combine(root, "paks"), []), []); + CliLaunchOptions Parse(params string[] extra) { + var options = CliLaunchOptions.Parse([command, "--chivalry2-path", root, "--platform", "Steam", .. extra]); + options.Shared.Assets = new ProfileAssetPaths(options.Shared.SavedDirSuffix, root); + return options; + } + string[] pin = ["--dll", "Chiv2-Community/UnchainedPlugin:v1.0.0"]; + var initial = Parse(pin); + var downloads = new DownloadHandler([1, 2, 3]); + using var client = new HttpClient(downloads); + Assert.True((await ServerLauncher.CreateDllSession(initial, registry, mods, client: client) + .PrepareLaunch(initial.ToLaunchOptions())).IsSome); + var installed = Path.Combine(initial.InstancePaths.Plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", older.DllFileName); + Assert.True(await DllManager.MatchesFileAsync(installed, older)); + + var unpinned = Parse("--no-plugin-update"); + Assert.False(unpinned.ToLaunchOptions().CheckForDependencyUpdates); + var error = await Assert.ThrowsAsync(() => + ServerLauncher.CreateDllSession(unpinned, registry, mods, client: client).PrepareLaunch(unpinned.ToLaunchOptions())); + Assert.Contains("Automatic plugin downloads are disabled; missing registered DLL", error.Message); + Assert.Contains("UnchainedPlugin:2.0.0", error.Message); + Assert.True(await DllManager.MatchesFileAsync(installed, older)); + + File.Delete(installed); + var pinned = Parse(["--no-plugin-update", .. pin]); + Assert.True((await ServerLauncher.CreateDllSession(pinned, registry, mods, client: client) + .PrepareLaunch(pinned.ToLaunchOptions())).IsSome); + Assert.True(await DllManager.MatchesFileAsync(installed, older)); + Assert.Equal(1, downloads.Requests); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public async Task SwitchingVersionsKeepsOnlySelectedDllInPluginsAndReusesExternalCache() { + var root = Path.Combine(Path.GetTempPath(), "dll-switch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var plugins = Path.Combine(root, "Plugins"); + var rc3 = Release("Chiv2-Community/UnchainedPlugin", "v1.0.0-RC3") with { DllFileName = "UnchainedPlugin.dll" }; + var rc2 = rc3 with { Tag = "v1.0.0-RC2", Hash = Convert.ToHexString(SHA256.HashData(new byte[] { 4, 5, 6 })).ToLowerInvariant() }; + var oldManagedPath = Path.Combine(plugins, ".dll-managed", rc2.Hash.ToLowerInvariant(), rc2.DllFileName); + Directory.CreateDirectory(Path.GetDirectoryName(oldManagedPath)!); + await File.WriteAllBytesAsync(oldManagedPath, [4, 5, 6]); + var packagePath = Path.Combine(root, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(rc3, rc2))); + var registry = new AggregateModRegistry(new LocalModRegistry(root)); + var mods = new ModManager(registry, new PakDir(root, []), []); + var options = new LaunchOptions([], Option.None, "", true, false, Option.None, Option.None); + foreach (var (release, bytes, download) in new[] { + (rc3, new byte[] { 1, 2, 3 }, true), + (rc2, new byte[] { 4, 5, 6 }, true), + (rc3, Array.Empty(), false) + }) { + using var client = new HttpClient(new DownloadHandler(bytes)); + var manager = new DllManager(registry, mods, plugins, + [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", release.Tag)], client: client); + var launch = download ? options : new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + if (!OperatingSystem.IsWindows()) { + await Assert.ThrowsAsync(() => manager.PrepareLaunch(launch)); + Assert.Equal(new byte[] { 4, 5, 6 }, await File.ReadAllBytesAsync(oldManagedPath)); + Assert.True(await DllManager.MatchesFileAsync(Path.Combine(root, ".cache", "dlls", rc3.Hash, rc3.DllFileName), rc3)); + Assert.False(File.Exists(Path.Combine(plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", rc3.DllFileName))); + return; + } + Assert.True((await manager.PrepareLaunch(launch)).IsSome); + var active = Assert.Single(Directory.EnumerateFiles(plugins, "*.dll", SearchOption.AllDirectories)); + Assert.True(await DllManager.MatchesFileAsync(active, release)); + Assert.False(Directory.Exists(Path.Combine(plugins, ".cache"))); + } + Assert.Equal(2, Directory.EnumerateFiles(Path.Combine(root, ".cache"), "*.dll", SearchOption.AllDirectories).Count()); + Assert.False(File.Exists(oldManagedPath)); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PakDependenciesAreEnabledOrRejectedWhenExistingSelectionConflicts(bool conflict) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-pak-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var registryRoot = Path.Combine(root, "registry"); + var plugins = Path.Combine(root, "plugins"); + Directory.CreateDirectory(plugins); + var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + release = release with { Info = release.Info with { ModDependencies = [new Dependency("https://github.com/org/mod", "<2.0.0")] } }; + var packagePath = Path.Combine(registryRoot, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); + var info = new ModInfo("https://github.com/org/mod", "test", "test", null, null, [], ["test"], []); + var pakReleases = new[] { "v1.0.0", "v2.0.0" }.Select(tag => new Release(tag, "hash", "mod.pak", DateTime.UtcNow, + info, new UnchainedLauncher.UnrealModScanner.JsonModels.AssetCollections([], [], [], [], []), null)).ToList(); + var metadata = Path.Combine(registryRoot, "org", "mod"); + Directory.CreateDirectory(metadata); + File.WriteAllText(Path.Combine(metadata, "mod.json"), JsonHelpers.Serialize(new Mod(info, pakReleases))); + var managed = Path.Combine(plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", "UnchainedPlugin.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(managed)!); + await File.WriteAllBytesAsync(managed, [1, 2, 3]); + var registry = new AggregateModRegistry(new LocalModRegistry(registryRoot)); + var mods = new ModManager(registry, new PakDir(root, []), conflict ? [new ReleaseCoordinates("org", "mod", "v2.0.0")] : []); + var manager = new DllManager(registry, mods, plugins); + var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + if (conflict) await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); + else { + Assert.True((await manager.PrepareLaunch(options)).IsSome); + Assert.Contains(new ReleaseCoordinates("org", "mod", "v1.0.0"), mods.EnabledModReleaseCoordinates); + Assert.True((await manager.PrepareLaunch(options)).IsSome); // Already enabled is not an error. + } + } + finally { Directory.Delete(root, recursive: true); } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DownloadsAreVerifiedBeforePublicationAndUnknownRootFilesArePreserved(bool valid) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-download-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + var packagePath = Path.Combine(root, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); + var plugins = Path.Combine(root, "Plugins"); + Directory.CreateDirectory(plugins); + var legacy = Path.Combine(plugins, "UnchainedPlugin.dll"); + await File.WriteAllBytesAsync(legacy, [9, 9, 9]); + var registry = new AggregateModRegistry(new LocalModRegistry(root)); + var mods = new ModManager(registry, new PakDir(root, []), []); + using var client = new HttpClient(new DownloadHandler(valid ? [1, 2, 3] : [3, 2, 1])); + var manager = new DllManager(registry, mods, plugins, client: client); + var options = new LaunchOptions([], Option.None, "", true, false, Option.None, Option.None); + var destination = Path.Combine(root, ".cache", "dlls", release.Hash.ToLowerInvariant(), release.DllFileName); + if (valid) { + Assert.True((await manager.PrepareLaunch(options)).IsSome); + Assert.True(await DllManager.MatchesFileAsync(destination, release)); + var installed = Assert.Single(Directory.GetFiles(Path.Combine(plugins, "Managed"), "*.dll", SearchOption.AllDirectories)); + Assert.True(await DllManager.MatchesFileAsync(installed, release)); + Assert.True(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + } + else { + await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + Assert.False(manager.Inject(new System.Diagnostics.Process())); + Assert.Equal(new byte[] { 9, 9, 9 }, await File.ReadAllBytesAsync(legacy)); + } + Assert.Equal(new byte[] { 9, 9, 9 }, await File.ReadAllBytesAsync(legacy)); + Assert.Empty(Directory.GetFiles(Path.Combine(root, ".cache", "dlls"), "*.tmp", SearchOption.AllDirectories)); + } + finally { Directory.Delete(root, recursive: true); } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs new file mode 100644 index 00000000..c3166175 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs @@ -0,0 +1,172 @@ +using System.CommandLine; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class DllQueryAndLoadingTests : IDisposable { + private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-query-" + Guid.NewGuid().ToString("N")); + public DllQueryAndLoadingTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Theory] + [InlineData("client")] + [InlineData("server")] + public void UnsafePermissionIsExplicitAndHiddenFromGeneratedHelp(string command) { + string[] args = [command, "--chivalry2-path", ".", "--platform", "Steam"]; + Assert.False(CliLaunchOptions.Parse(args).Shared.UnsafeLoadAllDlls); + Assert.True(CliLaunchOptions.Parse([.. args, UnsafeDllLoading.FlagName]).Shared.UnsafeLoadAllDlls); + Assert.False(CliLaunchOptions.Parse([.. args, "--", UnsafeDllLoading.FlagName]).Shared.UnsafeLoadAllDlls); + var help = CommandLineOptions.Parse([command, "--help"]); + Assert.DoesNotContain(UnsafeDllLoading.FlagName, help.HelpText); + } + + [Fact] + public void UnsafePermissionSurvivesAuthoritativeYamlOnlyWhenExplicitlyOnCommandLine() { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\n"); + Assert.False(CliLaunchOptions.Parse(["client", "--config-path", path]).Shared.UnsafeLoadAllDlls); + Assert.True(CliLaunchOptions.Parse(["client", "--config-path", path, UnsafeDllLoading.FlagName]).Shared.UnsafeLoadAllDlls); + File.AppendAllText(path, "unsafe-very-dangerous-load-all-dlls: true\n"); + Assert.Throws(() => CliLaunchOptions.Parse(["client", "--config-path", path])); + } + + [Theory] + [InlineData("--unsafe-very-dangerous-load-all-dlls=true")] + [InlineData("--unsafe-very-dangerous-load-all-dlls=false")] + public void PermissionIsAValueLessFlagEvenWhenYamlIgnoresOtherArguments(string flag) { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\n"); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", path, flag])); + Assert.Throws(() => UnsafeDllLoading.ParseGuiArguments([flag])); + } + + [Fact] + public void DuplicatePermissionAndQueryPermissionAreRejected() { + Assert.Throws(() => CliLaunchOptions.Parse(["client", "--chivalry2-path", ".", "--platform", "Steam", UnsafeDllLoading.FlagName, UnsafeDllLoading.FlagName])); + Assert.Throws(() => UnsafeDllLoading.ParseGuiArguments([UnsafeDllLoading.FlagName, UnsafeDllLoading.FlagName])); + Assert.Throws(() => CommandLineOptions.Parse(["dll-query", "list", UnsafeDllLoading.FlagName])); + } + + [Fact] + public void GuiPermissionIsRemovedFromGameArgumentsAndDoesNotConsumeAuthOrSeparatorTail() { + string[] args = ["--startunchained", "-AUTH_PASSWORD=secret with spaces", UnsafeDllLoading.FlagName, "--", UnsafeDllLoading.FlagName]; + var parsed = UnsafeDllLoading.ParseGuiArguments(args); + Assert.True(parsed.Enabled); + Assert.Equal(new[] { "--startunchained", "-AUTH_PASSWORD=secret with spaces", "--", UnsafeDllLoading.FlagName }, parsed.RemainingArguments); + var tailOnly = UnsafeDllLoading.ParseGuiArguments(["--", UnsafeDllLoading.FlagName]); + Assert.False(tailOnly.Enabled); + Assert.Equal(new[] { "--", UnsafeDllLoading.FlagName }, tailOnly.RemainingArguments); + } + + [Fact] + public void GuiUnsafeOptionIsHiddenAndResponseFilesCannotGrantPermission() { + var root = new RootCommand(); + var flag = UnsafeDllLoading.CreateOption(); + root.Options.Add(flag); + Assert.True(flag.Hidden); + Assert.False(UnsafeDllLoading.ParseGuiArguments(["@not-a-permission-file", "--startunchained"]).Enabled); + Assert.False(UnsafeDllLoading.ParseGuiArguments(["-AUTH_PASSWORD=" + UnsafeDllLoading.FlagName]).Enabled); + } + + [Fact] + public void DefaultInjectionSelectsOnlyRequiredPluginAndUnsafeSelectsAllRecursiveDlls() { + var nested = Path.Combine(_directory, "nested"); + Directory.CreateDirectory(nested); + foreach (var file in new[] { "UnchainedPlugin.dll", "Unexpected.dll", "not-a-dll.txt", "nested/Other.DLL" }) + File.WriteAllText(Path.Combine(_directory, file), "not executed"); + Assert.Empty(new DllInjector(_directory).GetDllPaths()); + Assert.Equal(3, new DllInjector(_directory, unsafeLoadAllDlls: true).GetDllPaths().Count()); + Assert.Empty(new DllInjector(Path.Combine(_directory, "missing")).GetDllPaths()); + } + + private const string Info = """ + {"repo_url":"https://github.com/org/plugin","name":"Example plugin","description":"Native plugin","authors":["Author"],"plugin_dependencies":[{"repo_url":"https://github.com/org/native","version":">=1.0.0"}],"mod_dependencies":[{"repo_url":"https://github.com/org/pak","version":"^1.0.0"}]} + """; + + private LocalModRegistry Registry() { + var packages = Path.Combine(_directory, "dll_db", "packages", "org"); + Directory.CreateDirectory(packages); + string Release(string tag, int day) => $$""" + {"tag":"{{tag}}","hash":"{{new string('a', 64)}}","dll_file_name":"Example.dll","download_url":"https://example.test/Example.dll","size":12345,"release_date":"2026-01-0{{day}}T00:00:00Z","info":{{Info}},"release_notes_markdown":"Notes"} + """; + File.WriteAllText(Path.Combine(packages, "plugin.json"), $$""" + {"latest_release_info":{{Info}},"releases":[{{Release("v1.0.0", 1)}},{{Release("v2.0.0", 2)}},{{Release("v3.0.0-rc.1", 3)}}]} + """); + return new LocalModRegistry(_directory); + } + + [Theory] + [InlineData("list")] + [InlineData("get")] + [InlineData("versions")] + public void DllCommandsReuseOneParserAndHidePermissionInHelp(string operation) { + var count = 0; + var help = CommandLineOptions.Parse(["dll-query", operation, "--help"], (root, tokens, config) => { + count++; + return root.Parse(tokens, config); + }); + Assert.Equal(1, count); + Assert.True(help.Help); + Assert.DoesNotContain(UnsafeDllLoading.FlagName, help.HelpText); + Assert.Contains("--add-registry", help.HelpText); + } + + [Fact] + public async Task DllQueryReadsExistingSchemaAndShowsIndependentDependencyKinds() { + var local = Registry(); + var registry = new AggregateModRegistry(local, local); + var list = Assert.IsType(CommandLineOptions.Parse(["dll-query", "list"])); + Assert.Equal(RegistryPackageKind.Dll, list.Kind); + var output = new StringWriter(); + Assert.Equal(0, await DllQuery.RunAsync(list, registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("org/plugin\tExample plugin\tv2.0.0", output.ToString()); + var detail = Assert.IsType(CommandLineOptions.Parse(["dll-query", "get", "org/plugin", "--version", "v1.0.0"])); + output = new StringWriter(); + Assert.Equal(0, await DllQuery.RunAsync(detail, registry, output, new StringWriter(), CancellationToken.None)); + foreach (var expected in new[] { "Version: v1.0.0", "Target: shared", "DLL file: Example.dll", "Hash (SHA-256): " + new string('a', 64), "Size: 12345 bytes", "org/native: >=1.0.0", "org/pak: ^1.0.0", "Release notes: Notes" }) + Assert.Contains(expected, output.ToString()); + var versions = Assert.IsType(CommandLineOptions.Parse(["dll-query", "versions", "org/plugin"])); + output = new StringWriter(); + Assert.Equal(0, await DllQuery.RunAsync(versions, registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("v3.0.0-rc.1", output.ToString()); + Assert.Equal(3, (await DllCatalog.ReadAsync(registry, CancellationToken.None)).Packages.Single().Releases.Count); + } + + [Fact] + public async Task MissingAndMalformedDllMetadataAreReportedWithoutLoadingAnything() { + var registry = new AggregateModRegistry(Registry()); + var query = Assert.IsType(CommandLineOptions.Parse(["dll-query", "get", "org/missing"])); + Assert.Equal(1, await DllQuery.RunAsync(query, registry, new StringWriter(), new StringWriter(), CancellationToken.None)); + File.WriteAllText(Path.Combine(_directory, "dll_db", "packages", "org", "plugin.json"), "{}"); + var catalog = await DllCatalog.ReadAsync(registry, CancellationToken.None); + Assert.Empty(catalog.Packages); + Assert.Single(catalog.Errors); + } + + [Fact] + public void DllQueryUsesConfiguredRegistriesAndQueryYamlCannotGrantUnsafePermission() { + Registry(); + var path = Path.Combine(_directory, "query.yaml"); + File.WriteAllText(path, "mod-setup: {registries: ['local:.']}\n"); + var query = Assert.IsType(CommandLineOptions.Parse(["dll-query", "get", "org/plugin", "--config-path", path, "--version", "v1.0.0"])); + Assert.Equal(RegistryPackageKind.Dll, query.Kind); + Assert.Equal(_directory, Assert.Single(query.RegistryOptions.Registries).Location.TrimEnd(Path.DirectorySeparatorChar)); + Assert.Equal("v1.0.0", query.Version); + Assert.Equal(2, query.RegistryOptions.CreateRegistry().ModRegistries.Count); + File.AppendAllText(path, "unsafe-very-dangerous-load-all-dlls: true\n"); + Assert.Throws(() => CommandLineOptions.Parse(["dll-query", "list", "--config-path", path])); + } + + [Theory] + [InlineData("\"latest_release_info\":null,\"releases\":[]")] + [InlineData("\"latest_release_info\":{},\"releases\":null")] + public async Task InvalidNullMetadataIsReportedAsCatalogError(string body) { + var registry = new AggregateModRegistry(Registry()); + File.WriteAllText(Path.Combine(_directory, "dll_db", "packages", "org", "plugin.json"), "{" + body + "}"); + var catalog = await DllCatalog.ReadAsync(registry, CancellationToken.None); + Assert.Single(catalog.Errors); + Assert.Empty(catalog.Packages); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/FileWriterTests.cs b/UnchainedLauncher.CLI.Tests/FileWriterTests.cs new file mode 100644 index 00000000..e6565f75 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/FileWriterTests.cs @@ -0,0 +1,76 @@ +using LanguageExt; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class FileWriterTests : IDisposable { + private readonly string _directory = Path.Combine( + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "writer-tests-" + Guid.NewGuid().ToString("N")); + + public FileWriterTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Theory] + [InlineData("success")] + [InlineData("output-failure")] + [InlineData("cancelled")] + [InlineData("abandoned")] + public async Task RegistryInputIsClosedDeterministically(string outcome) { + var source = Path.Combine(_directory, "source.pak"); + File.WriteAllText(source, "registry payload"); + var info = new ModInfo("https://github.com/org/mod", "mod", "test", null, null, [], [], []); + var release = new Release("v1.0.0", "hash", "test.pak", DateTime.UtcNow, info, + new AssetCollections([], [], [], [], []), null); + var registry = new LocalModRegistry(Path.Combine(_directory, "registry")); + await registry.AddRelease(release, source); + var output = outcome == "output-failure" ? _directory : Path.Combine(_directory, "output.pak"); + using var writer = await registry.DownloadPak(ReleaseCoordinates.FromRelease(release), output) + .Match(Right: value => value, Left: error => throw new Exception(error.Message)); + Assert.True(writer.InputStream.CanRead); + if (outcome == "abandoned") { + writer.Dispose(); + } + else { + using var cancellation = new CancellationTokenSource(); + if (outcome == "cancelled") cancellation.Cancel(); + var result = await writer.WriteAsync(Option>.None, cancellation.Token); + Assert.Equal(outcome == "success", result.IsRight); + } + // Check before fixture/writer disposal: Linux must detect the same leak as Windows. + Assert.False(writer.InputStream.CanRead); + var registryPak = Path.Combine(registry.RegistryPath, info.Organization, info.RepoName, release.Tag, release.PakFileName); + using var exclusive = File.Open(registryPak, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + if (outcome == "success") Assert.Equal("registry payload", File.ReadAllText(output)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InputIsClosedAfterReadFailureOrMidCopyCancellation(bool cancel) { + using var cancellation = new CancellationTokenSource(); + using var input = new InterruptingStream(cancellation, cancel); + using var writer = new FileWriter(Path.Combine(_directory, "output.pak"), input, input.Length); + var result = await writer.WriteAsync(Option>.None, cancellation.Token); + Assert.True(result.IsLeft); + Assert.False(input.CanRead); + Assert.Equal(cancel, cancellation.IsCancellationRequested); + } + + private sealed class InterruptingStream(CancellationTokenSource cancellation, bool cancel) : MemoryStream(new byte[16]) { + private bool _firstRead = true; + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { + if (_firstRead) { + _firstRead = false; + buffer.Span[0] = 1; + return ValueTask.FromResult(1); + } + if (!cancel) throw new IOException("Input read failed."); + cancellation.Cancel(); + return base.ReadAsync(buffer, cancellationToken); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs b/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs new file mode 100644 index 00000000..291dd9db --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs @@ -0,0 +1,186 @@ +using System.Globalization; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class IniOverrideTests : IDisposable { + private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), "ini-overrides-" + Guid.NewGuid().ToString("N")); + private const string Section = "/Script/TBL.TBLGameMode"; + + public IniOverrideTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, true); + + private ServerOptions Parse(string fields) { + var path = Path.Combine(_directory, "server.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\nserver:\n ini:\n Game:\n " + Section + ":\n" + + string.Join("\n", fields.Split('\n').Select(line => " " + line))); + return Assert.IsType(CommandLineOptions.Parse(["server", "--config-path", path])); + } + + [Fact] + public void InferredFieldsPatchOnlySelectedKeysAndReplaceLists() { + var options = Parse(""" + CustomMaps: {type: 'String[]', value: [FFA_Courtyard, FFA_Wardenglade]} + BotBackfillEnabled: true + ClassLimits: + - Class: Poleman + ClassLimitPercent: 10 + - Class: Archer + ClassLimitPercent: 0 + """); + var path = Path.Combine(_directory, "Game.ini"); + File.WriteAllText(path, $"; preserve comment\n[{Section}]\nServerName=Original\nBotBackfillEnabled=False\n+CustomMaps=Old\n+MapList=KeepMap\n.ClassLimits=(Class=Knight,ClassLimitPercent=25)\nCustom=keep-me\n[/Other]\nUntouched=yes\n"); + options.Ini.Apply(_directory); + var text = File.ReadAllText(path); + Assert.Contains("ServerName=Original", text); + Assert.Contains("Custom=keep-me", text); + Assert.Contains("Untouched=yes", text); + Assert.Contains("; preserve comment", text); + Assert.Contains("BotBackfillEnabled=True", text); + Assert.Contains("!CustomMaps=ClearArray", text); + Assert.Contains(".CustomMaps=FFA_Courtyard", text); + Assert.Contains(".CustomMaps=FFA_Wardenglade", text); + Assert.Contains("+MapList=KeepMap", text); + Assert.Contains(".ClassLimits=(Class=Poleman,ClassLimitPercent=10)", text); + Assert.Contains(".ClassLimits=(Class=Archer,ClassLimitPercent=0)", text); + Assert.DoesNotContain("CustomMaps=Old", text); + Assert.DoesNotContain("Class=Knight", text); + options.Ini.Apply(_directory); + Assert.Equal(text, File.ReadAllText(path)); + } + + [Fact] + public void ExplicitAndInferredWrappersAndPositionalParametersWork() { + var options = Parse(""" + CustomMaps: {type: 'String[]', value: []} + BotBackfillEnabled: {value: false} + ClassLimits: {type: 'ClassLimit[]', value: [[Archer, 0.25]]} + Custom: {type: String, value: example} + Balance: {type: AutoBalance, value: [0, 64, 32]} + Callbacks: {type: CallbackList, value: [first, second]} + """); + options.Ini.Apply(_directory); + var text = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("!CustomMaps=ClearArray", text); + Assert.Contains("BotBackfillEnabled=False", text); + Assert.Contains(".ClassLimits=(Class=Archer,ClassLimitPercent=0.25)", text); + Assert.Contains("Custom=example", text); + Assert.Contains("Balance=(MinNumPlayers=0,MaxNumPlayers=64,AllowedNumPlayersDifference=32)", text); + Assert.Contains("Callbacks=(first, second)", text); + } + + [Fact] + public void RawIsNotParsedOrWrittenToProfile() { + var options = Parse("Unknown: {type: raw, value: ' SomeStructuredThing(foo, bar, 10, baz, false) '}"); + Assert.Equal(new UEINIParameter("Game", Section, "Unknown", " SomeStructuredThing(foo, bar, 10, baz, false) ").Rendered, + Assert.Single(options.Ini.RawArguments)); + Assert.Contains(Assert.Single(options.Ini.RawArguments), options.ToLaunchOptions().LaunchArgs); + options.Ini.Apply(_directory); + Assert.False(File.Exists(Path.Combine(_directory, "Game.ini"))); + } + + [Theory] + [InlineData("String", "example", "example")] + [InlineData("Boolean", "true", "True")] + [InlineData("Bool", "false", "False")] + [InlineData("Int32", "-12", "-12")] + [InlineData("Int", "123", "123")] + [InlineData("Single", "1.5", "1.5")] + [InlineData("Float", "1.25", "1.25")] + [InlineData("Double", "2.5", "2.5")] + [InlineData("Decimal", "0.25", "0.25")] + [InlineData("CharacterClass", "poleman", "Poleman")] + public void ExplicitPrimitiveAndEnumTypesUseMappedCodecs(string type, string value, string expected) { + Parse($"Unknown: {{type: {type}, value: {value}}}").Ini.Apply(_directory); + Assert.Contains("Unknown=" + expected, File.ReadAllText(Path.Combine(_directory, "Game.ini"))); + } + + [Theory] + [InlineData("Unknown: example", "Unknown field")] + [InlineData("Unknown: [a, b]", "supply { type:")] + [InlineData("Unknown: {value: example}", "Unknown field")] + [InlineData("Unknown: {type: raw, value: [a]}", "raw requires a string")] + [InlineData("Unknown: {type: SecretType, value: secret}", "Unsupported type")] + [InlineData("Unknown: {type: String}", "supply value")] + [InlineData("Unknown: {type: String, value: x, typo: y}", "Use only type and value")] + [InlineData("BotBackfillEnabled: nope", "Boolean")] + [InlineData("BotBackfillEnabled: {type: String, value: nope}", "requires type Boolean")] + [InlineData("BotBackfillLowBots: 1.5", "Int32")] + [InlineData("CustomMaps: {type: 'String[]', value: map}", "require a list")] + [InlineData("CustomMaps: {type: 'String[]', value: [null]}", "cannot be null")] + [InlineData("ClassLimits: [{Class: Invalid, ClassLimitPercent: 0}]", "CharacterClass")] + [InlineData("ClassLimits: [{Class: Archer}]", "Missing ClassLimit.ClassLimitPercent")] + [InlineData("ClassLimits: [{Class: Archer, ClassLimitPercent: 0, Typo: 1}]", "Invalid or duplicate member")] + [InlineData("ClassLimits: [[Archer]]", "requires a member mapping")] + [InlineData("Unknown: {type: String, value: \"a\\nb\"}", "line breaks")] + [InlineData("MinTimeBeforeStartingMatch: NaN", "finite")] + [InlineData("ClassLimits: []\nclasslimits: []", "Duplicate INI field")] + public void InvalidValuesFailBeforeFilesAreWritten(string fields, string error) { + var exception = Assert.Throws(() => Parse(fields)); + Assert.Contains(error, exception.Message); + Assert.False(File.Exists(Path.Combine(_directory, "Game.ini"))); + } + + [Fact] + public void InvariantNumbersAndNoSecretValuesInErrors() { + var previous = CultureInfo.CurrentCulture; + try { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var options = Parse("ClassLimits: [[Archer, 0.25]]"); + options.Ini.Apply(_directory); + Assert.Contains("ClassLimitPercent=0.25", File.ReadAllText(Path.Combine(_directory, "Game.ini"))); + var error = Assert.Throws(() => Parse("BotBackfillEnabled: secret-password")); + Assert.DoesNotContain("secret-password", error.Message); + } + finally { CultureInfo.CurrentCulture = previous; } + } + + [Theory] + [InlineData("ServerName: Custom")] + [InlineData("ServerName: {type: raw, value: Custom}")] + public void ExplicitServerNameIsNotShadowedByGeneratedName(string yaml) { + var options = Parse(yaml); + Assert.DoesNotContain(options.ToServerLaunchOptions().ToCLIArgs(), arg => arg is UEINIParameter { Key: "ServerName" }); + } + + [Fact] + public void ProfilePreparationUsesNormalizedSuffixAndAppliesOnlyRequestedFields() { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, """ + chiv2-path: . + platform: Steam + server: + saved-dir-suffix: My-Server + ini: + Engine: + /Script/OnlineSubsystemUtils.IpNetDriver: + NetServerMaxTickRate: 30 + ConnectionTimeout: 12.5 + Game: + /Script/TBL.TBLTitleScreen: + bSavedHasAgreedToTOS: false + /Script/TBL.TBLGameMode: + ServerName: INI Name + GameUserSettings: + CustomSection: + PluginOption: {type: Boolean, value: true} + """); + var options = Assert.IsType(CommandLineOptions.Parse(["server", "--config-path", path])); + File.WriteAllText(Path.Combine(_directory, "Engine.ini"), "[/Script/OnlineSubsystemUtils.IpNetDriver]\nMaxClientRate=12345\n"); + ServerLauncher.InitializeServerProfile(options, suffix => { + Assert.Equal("MyServer", suffix); + return _directory; + }); + var engine = File.ReadAllText(Path.Combine(_directory, "Engine.ini")); + Assert.Contains("MaxClientRate=12345", engine); + Assert.Contains("NetServerMaxTickRate=30", engine); + Assert.Contains("ConnectionTimeout=12.5", engine); + Assert.DoesNotContain("InitialConnectTimeout=", engine); + var game = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("bSavedHasAgreedToTOS=False", game); + Assert.Contains("ServerName=INI Name", game); + Assert.DoesNotContain("bSavedHasAgreedToTOS=True", options.ToLaunchOptions().LaunchArgs); + Assert.Contains("PluginOption=True", File.ReadAllText(Path.Combine(_directory, "GameUserSettings.ini"))); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs b/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs new file mode 100644 index 00000000..b011e3e9 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs @@ -0,0 +1,42 @@ +using UnchainedLauncher.Core.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class LaunchCompositionTests { + [Theory] + [InlineData("server")] + [InlineData("client")] + public void BothLaunchTypesComposeSharedSettingsAndUseCorePlatform(string command) { + var options = CliLaunchOptions.Parse([ + command, "--chivalry2-path", ".", "--platform", "EGS", "--saved-dir-suffix=Common", + "--no-plugin-update", "--allow-unstable-plugin", "--mod=org/test:v1", + "--dll=org/plugin:v2", "--add-registry=github:owner/repo", "--", "-log" + ]); + Assert.Equal(command == "server", options is ServerOptions); + Assert.Equal(command == "client", options is ClientOptions); + var shared = Assert.IsType(options.Shared); + Assert.Equal(InstallationType.EpicGamesStore, shared.Platform); + Assert.Equal("Common", shared.SavedDirSuffix); + Assert.False(shared.UpdatePlugin); + Assert.True(shared.AllowUnstablePlugin); + Assert.Single(shared.Mods); + Assert.Single(shared.Dlls); + Assert.Single(shared.Registries); + Assert.Equal(new[] { "-log" }, shared.GameArguments); + var core = options.ToLaunchOptions(); + Assert.False(core.CheckForDependencyUpdates); + Assert.True(core.AllowUnstablePluginReleases); + Assert.Equal(command == "server", core.ServerLaunchOptions.IsSome); + } + + [Fact] + public void ServerHasNoClientModeFlagAndClientHasNoServerSettings() { + Assert.Null(typeof(ServerOptions).GetProperty("Client")); + Assert.Null(typeof(ClientOptions).GetProperty("Settings")); + Assert.Null(typeof(ClientOptions).GetMethod("ToServerLaunchOptions")); + Assert.Null(typeof(ServerOptions).GetProperty("UpdatePlugin")); + Assert.Null(typeof(ClientOptions).GetProperty("UpdatePlugin")); + Assert.Equal(typeof(InstallationType), typeof(SharedLaunchOptions).GetProperty("Platform")!.PropertyType); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/LaunchExampleTests.cs b/UnchainedLauncher.CLI.Tests/LaunchExampleTests.cs new file mode 100644 index 00000000..e1d5a9fe --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/LaunchExampleTests.cs @@ -0,0 +1,46 @@ +using UnchainedLauncher.Core.Services.Mods.Registry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class LaunchExampleTests { + private const string Plugin = "Chiv2-Community/UnchainedPlugin:v1.0.0-RC3"; + private const string Framework = "Chiv2-Community/Unchained-Mods:v0.1.0"; + private const string Mod = "Chiv2-Community/Chiv2Turbo:v0.0.4"; + private const string Actor = "/Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C"; + + [Theory] + [InlineData("client")] + [InlineData("server")] + public void DirectLaunchExamplesSelectPakAndDllDependencies(string command) { + var options = CliLaunchOptions.Parse([command, "--chivalry2-path", ".", "--platform", "Steam", + "--dll", Plugin, "--mod", Framework, "--mod", Mod, + .. command == "server" ? new[] { "--starting-map", "FFA_Courtyard", "--server-mod", Actor } : []]); + AssertDependencies(options.Shared); + if (options is ServerOptions server) { + Assert.Equal("FFA_Courtyard", server.ToServerLaunchOptions().Map); + Assert.Equal([Actor], server.ToServerLaunchOptions().ServerMods); + } + } + + [Theory] + [InlineData("server.yaml")] + [InlineData("servers.yaml")] + public void YamlLaunchExamplesSelectPakDllAndActorDependencies(string file) { + var command = CommandLineOptions.Parse(["server", "--config-path", Path.Combine(AppContext.BaseDirectory, "examples", file)]); + var servers = command is ServerGroupOptions group ? group.Servers.Select(entry => entry.Options) + : [Assert.IsType(command)]; + Assert.Equal(file == "servers.yaml" ? 2 : 1, servers.Count()); + Assert.All(servers, server => { + AssertDependencies(server.Shared); + Assert.Equal("FFA_Courtyard", server.ToServerLaunchOptions().Map); + Assert.Equal([Actor], server.ToServerLaunchOptions().ServerMods); + }); + } + + private static void AssertDependencies(SharedLaunchOptions options) { + Assert.Equal(new[] { new ReleaseCoordinates("Chiv2-Community", "Unchained-Mods", "v0.1.0"), + new ReleaseCoordinates("Chiv2-Community", "Chiv2Turbo", "v0.0.4") }, options.Mods); + Assert.Equal(new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", "v1.0.0-RC3"), Assert.Single(options.Dlls)); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs new file mode 100644 index 00000000..26f354f3 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs @@ -0,0 +1,277 @@ +using System.Net; +using System.Security.Cryptography; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class LaunchPreparationTests : IDisposable { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "launch-preparation-" + Guid.NewGuid().ToString("N")); + + public LaunchPreparationTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Fact] + public async Task ProfilePreparationPreservesUnknownSignaturesAcrossLaunches() { + var local = new LocalModRegistry(Path.Combine(_directory, "registry")); + var source = Path.Combine(_directory, "payload.pak"); + File.WriteAllText(source, "payload"); + var hash = FileHelpers.Sha512(source).Match(value => value, error => throw new Exception(error.Message)); + foreach (var id in new[] { "Chiv2-Community/Unchained-Mods", "org/mod" }) { + var release = new Release("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo("https://github.com/" + id, id.Split('/')[1], "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + await local.AddRelease(release, source); + } + AddPlugin(local, "org/mod", ">=1.0.0"); + var request = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "Steam"]); + request.Shared.Assets = new ProfileAssetPaths(request.Shared.SavedDirSuffix, _directory); + var basePaks = Path.Combine(_directory, FilePaths.PakDir); + Directory.CreateDirectory(basePaks); + File.WriteAllText(Path.Combine(basePaks, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var registry = new AggregateModRegistry(local); + var manager = ServerLauncher.CreateModManager(request, registry); + Assert.False((await manager.UpdateModsList()).HasErrors); + var unknownSigs = new[] { "UserBackup.sig", "mod_1_P.sig" }; + foreach (var name in unknownSigs) File.WriteAllText(Path.Combine(manager.PakDir.DirPath, name), "unknown " + name); + using var client = new HttpClient(new DownloadHandler()); + var session = new DllLaunchSession(registry, manager, request.InstancePaths.Plugins, client: client, + cacheDirectory: Path.Combine(_directory, "dll-cache")); + var preparer = ServerLauncher.CreateLaunchPreparer(request, manager, session, new ModManagerCodec(registry), + Path.Combine(_directory, "host.json")); + ManagedPak[]? first = null; + for (var i = 0; i < 2; i++) { + Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); + var owned = Assert.IsType(manager.PakDir).ManagedPaks.ToArray(); + Assert.Equal("mod (1)_1_P.pak", Assert.Single(owned, pak => pak.Coordinates.ModuleName == "mod").PakFileName); + if (first is not null) Assert.Equal(first, owned); + first = owned; + foreach (var pak in owned) Assert.Equal("signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(manager.PakDir.DirPath, pak.PakFileName), ".sig"))); + foreach (var name in unknownSigs) Assert.Equal("unknown " + name, File.ReadAllText(Path.Combine(manager.PakDir.DirPath, name))); + } + } + + [Theory] + [InlineData("client", true, false)] + [InlineData("server", true, false)] + [InlineData("client", true, true)] + [InlineData("server", true, true)] + [InlineData("client", false, false)] + public async Task PreparationValidatesDllSelectedPakDependenciesBeforeInstalling( + string command, bool hasTransitiveDependency, bool dependencyPublished) { + var local = new LocalModRegistry(Path.Combine(_directory, "registry")); + var source = Path.Combine(_directory, "payload.pak"); + File.WriteAllText(source, "pak payload"); + var hash = FileHelpers.Sha512(source).Match(value => value, error => throw new Exception(error.Message)); + Release Pak(string id, string tag, params Dependency[] dependencies) => new(tag, hash, "mod.pak", + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new ModInfo("https://github.com/" + id, id.Split('/')[1], "test", null, null, [], ["test"], dependencies.ToList()), + new AssetCollections([], [], [], [], []), null); + await local.AddRelease(Pak("Chiv2-Community/Unchained-Mods", "v1.0.0"), source); + await local.AddRelease(Pak("org/required", "v1.0.0", hasTransitiveDependency + ? [new Dependency("https://github.com/org/transitive", "*")] : []), source); + // Core traverses latest-release metadata, which omits the older release's dependency. + await local.AddRelease(Pak("org/required", "v2.0.0"), source); + if (dependencyPublished) await local.AddRelease(Pak("org/transitive", "v1.0.0"), source); + + AddPlugin(local, "org/required", "<2.0.0"); + + var request = CliLaunchOptions.Parse([ + command, "--chivalry2-path", _directory, "--platform", "Steam", + .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [] + ]); + var pakPath = Path.Combine(_directory, FilePaths.PakDir); + request.Shared.Assets = new ProfileAssetPaths(request.Shared.SavedDirSuffix, _directory); + Directory.CreateDirectory(pakPath); + File.WriteAllText(Path.Combine(pakPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var registry = new AggregateModRegistry(local); + var manager = ServerLauncher.CreateModManager(request, registry); + Assert.False((await manager.UpdateModsList()).HasErrors); + ServerLauncher.ValidateReleases(manager, request.Shared.Mods); + Assert.Empty(manager.EnabledModReleaseCoordinates); + + using var client = new HttpClient(new DownloadHandler()); + var plugins = Path.Combine(_directory, "plugins"); + var session = new DllLaunchSession(registry, manager, plugins, client: client, + cacheDirectory: Path.Combine(_directory, "dll-cache")); + var cachePath = Path.Combine(_directory, "cli_mod_manager.json"); + var preparer = ServerLauncher.CreateLaunchPreparer(request, manager, session, new ModManagerCodec(registry), cachePath); + if (hasTransitiveDependency) { + var error = await Assert.ThrowsAsync(() => preparer.PrepareLaunch(request.ToLaunchOptions())); + Assert.Contains(dependencyPublished ? "Core did not resolve dependency org/transitive" : "Missing stable dependency: org/transitive", error.Message); + Assert.Empty(Directory.GetFiles(request.InstancePaths.Paks, "*.pak")); + Assert.False(File.Exists(cachePath)); + } + else { + Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); + Assert.True(File.Exists(cachePath)); + var paks = Directory.GetFiles(request.InstancePaths.Paks, "*.pak"); + Assert.Equal(2, paks.Length); + Assert.All(paks, pak => Assert.Equal("signature", File.ReadAllText(Path.ChangeExtension(pak, ".sig")))); + } + Assert.True(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + Assert.Contains(manager.EnabledModReleaseCoordinates, coordinate => + coordinate.Org == "org" && coordinate.ModuleName == "required" && coordinate.Version == "v1.0.0"); + } + + [Theory] + [InlineData("cycle", true)] + [InlineData("self", true)] + [InlineData("acyclic", true)] + [InlineData("cycle", false)] + [InlineData("self", false)] + [InlineData("acyclic", false)] + public async Task DependencySortMustRetainEverySelectedReleaseBeforePakMutation(string graph, bool validateDirectly) { + var local = new LocalModRegistry(Path.Combine(_directory, "registry")); + var source = Path.Combine(_directory, "payload.pak"); + File.WriteAllText(source, "pak payload"); + var hash = FileHelpers.Sha512(source).Match(value => value, error => throw new Exception(error.Message)); + Release Pak(string id, params string[] dependencies) => new("v1.0.0", hash, "mod.pak", + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new ModInfo("https://github.com/" + id, id.Split('/')[1], "test", null, null, [], ["test"], + dependencies.Select(dep => new Dependency("https://github.com/" + dep, "*")).ToList()), + new AssetCollections([], [], [], [], []), null); + await local.AddRelease(Pak("Chiv2-Community/Unchained-Mods"), source); + await local.AddRelease(Pak("org/a", graph == "self" ? "org/a" : "org/b"), source); + await local.AddRelease(Pak("org/b", graph == "cycle" ? ["org/a"] : []), source); + AddPlugin(local, "org/a", ">=1.0.0"); + + var request = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "Steam"]); + request.Shared.Assets = new ProfileAssetPaths(request.Shared.SavedDirSuffix, _directory); + var pakPath = Path.Combine(_directory, FilePaths.PakDir); + Directory.CreateDirectory(pakPath); + File.WriteAllText(Path.Combine(pakPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var registry = new AggregateModRegistry(local); + var manager = ServerLauncher.CreateModManager(request, registry); + Assert.False((await manager.UpdateModsList()).HasErrors); + var selected = new ReleaseCoordinates("org", "a", "v1.0.0"); + if (validateDirectly) { + manager.EnableModRelease(selected); + if (graph == "acyclic") ServerLauncher.ValidateReleases(manager, [selected]); + else Assert.Contains("cycle", Assert.Throws(() => + ServerLauncher.ValidateReleases(manager, [selected])).Message); + Assert.Empty(Directory.GetFiles(request.InstancePaths.Paks, "*.pak")); + return; + } + ServerLauncher.ValidateReleases(manager, []); + + var unknown = Path.Combine(request.InstancePaths.Paks, "unknown.pak"); + File.WriteAllText(unknown, "preserve unknown bytes"); + using var client = new HttpClient(new DownloadHandler()); + var session = new DllLaunchSession(registry, manager, Path.Combine(_directory, "plugins"), client: client, + cacheDirectory: Path.Combine(_directory, "dll-cache")); + var cachePath = Path.Combine(_directory, "cli_mod_manager.json"); + var preparer = ServerLauncher.CreateLaunchPreparer(request, manager, session, new ModManagerCodec(registry), cachePath); + if (graph == "acyclic") { + Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); + Assert.Equal(4, Directory.GetFiles(request.InstancePaths.Paks, "*.pak").Length); + } + else { + var error = await Assert.ThrowsAsync(() => preparer.PrepareLaunch(request.ToLaunchOptions())); + Assert.Contains("cycle", error.Message); + Assert.Equal([unknown], Directory.GetFiles(request.InstancePaths.Paks, "*.pak")); + Assert.False(File.Exists(cachePath)); + } + Assert.Contains(selected, manager.EnabledModReleaseCoordinates); + Assert.Equal("preserve unknown bytes", File.ReadAllText(unknown)); + } + + [Theory] + [InlineData(false, null, false)] + [InlineData(true, null, false)] + [InlineData(false, "v1.0.0", false)] + [InlineData(true, "v1.0.0", false)] + [InlineData(false, "v2.0.0", false)] + [InlineData(true, "v2.0.0", false)] + [InlineData(false, null, true)] + [InlineData(true, null, true)] + public async Task PreparationIntersectsDllPakRequirementsBeforeEnabling( + bool reverse, string? selectedTag, bool incompatible) { + var local = new LocalModRegistry(Path.Combine(_directory, "registry")); + var source = Path.Combine(_directory, "payload.pak"); + File.WriteAllText(source, "pak payload"); + var hash = FileHelpers.Sha512(source).Match(value => value, error => throw new Exception(error.Message)); + Release Pak(string id, string tag) => new(tag, hash, "mod.pak", + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new ModInfo("https://github.com/" + id, id.Split('/')[1], "test", null, null, [], ["test"], []), + new AssetCollections([], [], [], [], []), null); + await local.AddRelease(Pak("Chiv2-Community/Unchained-Mods", "v1.0.0"), source); + await local.AddRelease(Pak("org/independent", "v1.0.0"), source); + foreach (var tag in new[] { "v1.0.0", "v1.5.0", "v2.0.0" }) + await local.AddRelease(Pak("org/shared", tag), source); + AddPlugin(local, "org/independent", ">=1.0.0"); + AddPlugin(local, "org/shared", incompatible ? ">=2.0.0" : ">=1.0.0", "org/A"); + AddPlugin(local, "ORG/shared", "<2.0.0", "org/B"); + + string[] dlls = reverse ? ["org/B:v1.0.0", "org/A:v1.0.0"] : ["org/A:v1.0.0", "org/B:v1.0.0"]; + var request = CliLaunchOptions.Parse([ + "client", "--chivalry2-path", _directory, "--platform", "Steam", + "--dll", dlls[0], "--dll", dlls[1], + .. selectedTag is null ? Array.Empty() : ["--mod", "org/shared:" + selectedTag] + ]); + request.Shared.Assets = new ProfileAssetPaths(request.Shared.SavedDirSuffix, _directory); + var pakPath = Path.Combine(_directory, FilePaths.PakDir); + Directory.CreateDirectory(pakPath); + File.WriteAllText(Path.Combine(pakPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var registry = new AggregateModRegistry(local); + var manager = ServerLauncher.CreateModManager(request, registry); + Assert.False((await manager.UpdateModsList()).HasErrors); + ServerLauncher.ValidateReleases(manager, request.Shared.Mods); + var original = manager.EnabledModReleaseCoordinates.ToArray(); + Assert.Equal(2, request.Shared.Dlls.Count); + using var client = new HttpClient(new DownloadHandler()); + var plugins = Path.Combine(_directory, "plugins"); + var session = new DllLaunchSession(registry, manager, plugins, request.Shared.Dlls, client: client, + cacheDirectory: Path.Combine(_directory, "dll-cache")); + var cachePath = Path.Combine(_directory, "cli_mod_manager.json"); + var preparer = ServerLauncher.CreateLaunchPreparer(request, manager, session, new ModManagerCodec(registry), cachePath); + + if (incompatible || selectedTag == "v2.0.0") { + var error = await Assert.ThrowsAsync(() => preparer.PrepareLaunch(request.ToLaunchOptions())); + Assert.Contains(selectedTag is null ? "DLL pak dependency unavailable" : "conflicts with selected mods", error.Message); + Assert.Equal(original, manager.EnabledModReleaseCoordinates); + Assert.Empty(Directory.GetFiles(request.InstancePaths.Paks, "*.pak")); + Assert.False(File.Exists(cachePath)); + Assert.False(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + } + else { + Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); + Assert.Contains(new ReleaseCoordinates("org", "shared", selectedTag ?? "v1.5.0"), manager.EnabledModReleaseCoordinates); + Assert.Single(manager.EnabledModReleaseCoordinates.Where(c => c.ModuleName == "shared")); + Assert.Equal(3, Directory.GetFiles(request.InstancePaths.Paks, "*.pak").Length); + Assert.True(File.Exists(cachePath)); + Assert.True(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + } + } + + private static void AddPlugin(LocalModRegistry local, string dependency, string version, + string identifier = "Chiv2-Community/UnchainedPlugin") { + var parts = identifier.Split('/'); + var dll = new DllRelease("v1.0.0", Convert.ToHexString(SHA256.HashData([1, 2, 3])).ToLowerInvariant(), + parts[1] + ".dll", "https://example.test/" + parts[1] + ".dll", 3, + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + new DllInfo { + RepoUrl = "https://github.com/" + identifier, + Name = "core", + Description = "test", + Authors = ["test"], + ModDependencies = [new Dependency("https://github.com/" + dependency, version)] + }, null); + var packages = Path.Combine(local.RegistryPath, "dll_db", "packages", parts[0]); + Directory.CreateDirectory(packages); + File.WriteAllText(Path.Combine(packages, parts[1] + ".json"), JsonHelpers.Serialize(new DllPackage(dll.Info, [dll]))); + } + + private sealed class DownloadHandler : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + RequestMessage = request, + Content = new ByteArrayContent([1, 2, 3]) + }); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs b/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs new file mode 100644 index 00000000..2172de70 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Net.Sockets; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class LocalAddressDetectorTests { + [Theory] + [InlineData("127.0.0.1")] + [InlineData("0.0.0.0")] + public async Task QueryUsesIpv4LoopbackForLocalAndWildcardAddresses(string address) { + using var listener = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var port = ((IPEndPoint)listener.Client.LocalEndPoint!).Port; + var options = Assert.IsType(CliLaunchOptions.Parse([ + "server", "--chivalry2-path", ".", "--platform", "Steam", "--local-ip", address, "--query-port", port.ToString() + ])); + var query = ServerLauncher.CreateQuery(options).InfoAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var request = await listener.ReceiveAsync(timeout.Token); + Assert.Equal(AddressFamily.InterNetwork, request.RemoteEndPoint.AddressFamily); + byte[] response = [255, 255, 255, 255, 73, 17, + .. "Test\0Map\0Folder\0Game\0"u8.ToArray(), 0, 0, 1, 32, 0, (byte)'D', (byte)'W', 0, 1]; + await listener.SendAsync(response, request.RemoteEndPoint, timeout.Token); + Assert.Equal("Test", (await query).Name); + } + + [Fact] + public void PrefersUsableDefaultGatewayAddressOverOtherInterfaces() { + var candidates = new[] { + (IPAddress.Parse("172.17.0.1"), false), + (IPAddress.Parse("192.168.1.50"), true), + (IPAddress.Loopback, true), + (IPAddress.IPv6Loopback, true) + }; + Assert.Equal(IPAddress.Parse("192.168.1.50"), LocalAddressDetector.Select(candidates)); + } + + [Fact] + public void FallsBackToAnActiveIpv4OrLoopbackWithoutGateway() { + Assert.Equal(IPAddress.Parse("10.0.0.2"), LocalAddressDetector.Select([(IPAddress.Parse("10.0.0.2"), false)])); + Assert.Equal(IPAddress.Loopback, LocalAddressDetector.Select([ + (IPAddress.Any, true), (IPAddress.Parse("169.254.1.2"), true), (IPAddress.IPv6Any, true) + ])); + Assert.Equal(IPAddress.Loopback, LocalAddressDetector.Select([])); + } + + [Fact] + public void ExplicitOverrideIsNotReplacedAndClientDoesNotGetServerLocalIp() { + var server = CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", "--local-ip=192.168.50.10"]); + Assert.Equal("192.168.50.10", Assert.IsType(server).Settings.LocalIp); + Assert.Contains("--local-ip 192.168.50.10", Assert.IsType(server).ToServerLaunchOptions().ToCLIArgs().Select(arg => arg.Rendered)); + var client = CliLaunchOptions.Parse(["client", "--chivalry2-path", ".", "--platform", "Steam"]); + Assert.IsType(client); + Assert.True(client.ToLaunchOptions().ServerLaunchOptions.IsNone); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs new file mode 100644 index 00000000..5b08038f --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -0,0 +1,337 @@ +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +[Collection("Installation directory")] +public class ManagedInventoryTests : IDisposable { + private readonly string _root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), + "inventory-tests-" + Guid.NewGuid().ToString("N")); + + public ManagedInventoryTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SequentialCliGuiCliSharesOwnershipButNotSelectedReleases(bool server) { + await InInstallation(async (registry, common, extra, launch) => { + if (!server) launch = launch with { ServerLaunchOptions = LanguageExt.Option.None }; + var codec = new ModManagerCodec(registry); + var cliCache = Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json"); + var cli = Manager(registry, []); + await Prepare(cli, launch); + codec.SerializeFile(cliCache, cli); + + // GUI has its own selection and may reorder the shared common pak. + var selected = ReleaseCoordinates.FromRelease(extra); + var gui = Manager(registry, [selected]); + await Prepare(gui, launch); + codec.SerializeFile(Path.GetFullPath(FilePaths.ModManagerConfigPath), gui); + var guiConfiguration = File.ReadAllText(FilePaths.ModManagerConfigPath); + var oldExtra = gui.PakDir.GetManagedPakFilePath(selected).IfNone(""); + Assert.True(File.Exists(oldExtra)); + + var secondCli = Manager(registry, []); + await Prepare(secondCli, launch); + codec.SerializeFile(cliCache, secondCli); + Assert.False(File.Exists(oldExtra)); + Assert.False(File.Exists(Path.ChangeExtension(oldExtra, ".sig"))); + AssertOnlyCommonAndUnknown(secondCli, common); + Assert.Equal(guiConfiguration, File.ReadAllText(FilePaths.ModManagerConfigPath)); + + // The saved GUI snapshot now names obsolete files. Shared ownership wins, + // while GUI's selected release survives and is installed on its next launch. + var decoded = codec.Deserialize(guiConfiguration); + Assert.True(decoded.Success); + gui = decoded.Result!; + Assert.Equal(selected, Assert.Single(gui.EnabledModReleaseCoordinates)); + await Prepare(gui, launch); + Assert.Equal(2, ((PakDir)gui.PakDir).ManagedPaks.Count()); + await Prepare(secondCli, launch); // also reconcile an already-created manager + AssertOnlyCommonAndUnknown(secondCli, common); + }); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task LegacyCachesMigrateOwnershipAndRemoveDuplicateCommonPaks(bool guiCache, bool cliCache) { + await InInstallation(async (registry, common, extra, launch) => { + var codec = new ModManagerCodec(registry); + var paks = Path.GetFullPath(FilePaths.PakDir); + var commonCoordinate = ReleaseCoordinates.FromRelease(common); + var extraCoordinate = ReleaseCoordinates.FromRelease(extra); + var oldCommon = new ManagedPak(commonCoordinate, "legacy-common.pak", 0); + var newCommon = new ManagedPak(commonCoordinate, "gui-common.pak", 1); + var guiOnly = new ManagedPak(extraCoordinate, "gui-only.pak", 0); + foreach (var pak in new[] { oldCommon, newCommon, guiOnly }) { + File.Copy(Path.Combine(_root, "payload.pak"), Path.Combine(paks, pak.PakFileName)); + File.WriteAllText(Path.Combine(paks, Path.ChangeExtension(pak.PakFileName, ".sig")), "signature"); + } + // A single legacy input must be sufficient, including duplicate releases. + if (guiCache) codec.SerializeFile(Path.GetFullPath(FilePaths.ModManagerConfigPath), + new ModManager(registry, new PakDir(FilePaths.PakDir, cliCache ? [newCommon, guiOnly] : [oldCommon, newCommon, guiOnly]), [extraCoordinate])); + if (cliCache) codec.SerializeFile(Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json"), + new ModManager(registry, new PakDir(paks, guiCache ? [oldCommon] : [oldCommon, newCommon, guiOnly]), [])); + var beforeGui = guiCache ? File.ReadAllText(FilePaths.ModManagerConfigPath) : null; + var manager = Manager(registry, []); + await Prepare(manager, launch); + AssertOnlyCommonAndUnknown(manager, common); + foreach (var pak in new[] { oldCommon, newCommon, guiOnly }) { + Assert.False(File.Exists(Path.Combine(paks, pak.PakFileName))); + Assert.False(File.Exists(Path.Combine(paks, Path.ChangeExtension(pak.PakFileName, ".sig")))); + } + if (guiCache) Assert.Equal(beforeGui, File.ReadAllText(FilePaths.ModManagerConfigPath)); + // Obsolete caches must not override the post-migration shared inventory. + await Prepare(Manager(registry, []), launch); + AssertOnlyCommonAndUnknown(manager, common); + }); + } + + private ModManager Manager(IModRegistry registry, ReleaseCoordinates[] selected) => + new(registry, new PakDir(Path.GetFullPath(FilePaths.PakDir), []), selected); + + public static IEnumerable InvalidInventories() { + yield return ["{", "Cannot read shared pak inventory."]; + yield return ["null", "Cannot read shared pak inventory."]; + foreach (var json in new[] { "{}", "{\"ManagedPaks\":null}", "{\"ManagedPaks\":[null]}" }) + yield return [json, "Invalid managed pak inventory."]; + var valid = new ManagedPak(new ReleaseCoordinates("org", "mod", "v1.0.0"), "owned.pak", 0); + foreach (var field in new[] { "Coordinates", "PakFileName", "Org", "ModuleName", "Version" }) { + foreach (var missing in new[] { false, true }) { + var entry = System.Text.Json.Nodes.JsonNode.Parse(JsonHelpers.Serialize(valid))!; + var parent = field is "Org" or "ModuleName" or "Version" ? entry["Coordinates"]! : entry; + if (missing) parent.AsObject().Remove(field); + else parent[field] = null; + yield return [$"{{\"ManagedPaks\":[{entry}]}}", "Invalid managed pak inventory."]; + } + } + var invalid = valid with { PakFileName = "../outside.pak" }; + yield return [JsonHelpers.Serialize(new PakDirMetadata("unused", [invalid])), "Invalid managed pak filename in inventory."]; + } + + [Theory] + [MemberData(nameof(InvalidInventories))] + public async Task InvalidInventoryCancelsBeforeInstallationWithoutChangingFiles(string inventoryJson, string diagnostic) { + await InInstallation(async (registry, common, extra, launch) => { + var owned = new ManagedPak(ReleaseCoordinates.FromRelease(common), "owned.pak", 0); + var paks = Path.GetFullPath(FilePaths.PakDir); + File.Copy(Path.Combine(_root, "payload.pak"), Path.Combine(paks, owned.PakFileName)); + File.WriteAllText(Path.Combine(paks, "owned.sig"), "owned signature"); + var manager = new ModManager(registry, new PakDir(paks, [owned]), []); + var inventoryPath = Path.Combine(manager.PakDir.DirPath, FilePaths.ManagedPakInventoryFileName); + File.WriteAllText(inventoryPath, inventoryJson); + var cache = Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json"); + Directory.CreateDirectory(FilePaths.ModCachePath); + File.WriteAllText(cache, "unchanged host cache"); + var before = Directory.GetFiles(_root, "*", SearchOption.AllDirectories).ToDictionary(path => path, File.ReadAllBytes); + var dialogue = new RejectingDialogue(); + var installer = new Chivalry2ModsInstaller(manager, dialogue); + Assert.True((await installer.PrepareLaunch(launch)).IsNone); + var process = new RejectingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(installer, process, _root, new NullInjector()); + Assert.True((await launcher.Launch(launch)).IsLeft); + Assert.Equal(0, process.Calls); + Assert.Equal(2, dialogue.Messages.Count); + Assert.All(dialogue.Messages, message => { + Assert.Contains(diagnostic, message); + Assert.Contains("Launch cancelled", message); + }); + Assert.Equal(owned, Assert.Single(((PakDir)manager.PakDir).ManagedPaks)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(_root, "*", SearchOption.AllDirectories).Order()); + foreach (var (path, bytes) in before) Assert.Equal(bytes, File.ReadAllBytes(path)); + }); + } + + [Fact] + public async Task EmptyInventoryIsAcceptedWithoutAdoptingUnmanagedFiles() { + await InInstallation(async (registry, common, extra, launch) => { + var manager = Manager(registry, []); + var inventoryPath = Path.Combine(manager.PakDir.DirPath, FilePaths.ManagedPakInventoryFileName); + File.WriteAllText(inventoryPath, "{\"ManagedPaks\":[]}"); + var before = Directory.GetFiles(_root, "*", SearchOption.AllDirectories) + .Where(path => path != inventoryPath).ToDictionary(path => path, File.ReadAllBytes); + await Prepare(manager, launch); + AssertOnlyCommonAndUnknown(manager, common); + foreach (var (path, bytes) in before) Assert.Equal(bytes, File.ReadAllBytes(path)); + }); + } + + private sealed class RejectingDialogue : IUserDialogueSpawner { + public List Messages { get; } = []; + public void DisplayMessage(string message) => Messages.Add(message); + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) => throw new InvalidOperationException("Unexpected repair prompt."); + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, + string noButtonText, string? cancelButtonText, IEnumerable updates) => throw new InvalidOperationException("Unexpected update prompt."); + public Action DisplayProgress(MemoryProgress progress) => throw new InvalidOperationException("Installation must not start."); + } + + private sealed class RejectingProcessLauncher : IProcessLauncher { + public string Executable => "test-game.exe"; + public int Calls { get; private set; } + public LanguageExt.Either Launch(string workingDirectory, string args) { + Calls++; + throw new InvalidOperationException("Process launch must not occur."); + } + } + + [Fact] + public async Task ServerInstancesInstallDifferentPakSetsWithoutChangingBaseOrPeerFiles() { + await InInstallation(async (source, common, extra, _) => { + var registry = new AggregateModRegistry(source); + var first = Assert.IsType(CliLaunchOptions.Parse([ + "server", "--chivalry2-path", _root, "--platform", "Steam", "--saved-dir-suffix", "One"])); + var second = Assert.IsType(CliLaunchOptions.Parse([ + "server", "--chivalry2-path", _root, "--platform", "Steam", "--saved-dir-suffix", "Two", + "--mod", $"{extra.Info.Organization}/{extra.Info.RepoName}:{extra.Tag}"])); + var baseFiles = Directory.GetFiles(Path.GetFullPath(FilePaths.PakDir)) + .ToDictionary(path => path, File.ReadAllBytes); + first.Shared.Assets = new ProfileAssetPaths(first.Shared.SavedDirSuffix, _root); + second.Shared.Assets = new ProfileAssetPaths(second.Shared.SavedDirSuffix, _root); + var firstManager = ServerLauncher.CreateModManager(first, registry); + await Prepare(firstManager, first.ToLaunchOptions()); + var firstFiles = Directory.GetFiles(first.InstancePaths.Paks).ToDictionary(path => path, File.ReadAllBytes); + var secondManager = ServerLauncher.CreateModManager(second, registry); + await Prepare(secondManager, second.ToLaunchOptions()); + + Assert.Single(((PakDir)firstManager.PakDir).ManagedPaks); + Assert.Equal(2, ((PakDir)secondManager.PakDir).ManagedPaks.Count()); + Assert.NotEqual(first.InstancePaths.Paks, second.InstancePaths.Paks); + Assert.Equal(baseFiles.Keys.Order(), Directory.GetFiles(Path.GetFullPath(FilePaths.PakDir)).Order()); + foreach (var (path, bytes) in baseFiles.Concat(firstFiles)) Assert.Equal(bytes, File.ReadAllBytes(path)); + Assert.True(File.Exists(Path.Combine(first.InstancePaths.Paks, FilePaths.ManagedPakInventoryFileName))); + Assert.True(File.Exists(Path.Combine(second.InstancePaths.Paks, FilePaths.ManagedPakInventoryFileName))); + + // Host metadata retains the external signature template when reloaded. + var codec = new ModManagerCodec(registry); + codec.SerializeFile(first.InstancePaths.ModMetadata, firstManager); + var restored = codec.Deserialize(File.ReadAllText(first.InstancePaths.ModMetadata)); + Assert.True(restored.Success); + var restoredPaks = Assert.IsType(restored.Result!.PakDir); + Assert.Equal(Path.Combine(Path.GetFullPath(FilePaths.PakDir), "pakchunk0-WindowsNoEditor.sig"), restoredPaks.DefaultSigPath); + var managed = Assert.Single(restoredPaks.ManagedPaks); + File.Delete(Path.ChangeExtension(Path.Combine(restoredPaks.DirPath, managed.PakFileName), ".sig")); + Assert.True(restoredPaks.SignAll().IsRight); + }); + } + + [Fact] + public async Task SharedOwnershipPreservesUnmanagedCollisionAndUpdatesPatchOrders() { + await InInstallation(async (registry, common, extra, launch) => { + var paks = Path.GetFullPath(FilePaths.PakDir); + var occupied = Path.Combine(paks, "Unchained-Mods_0_P.pak"); + File.WriteAllText(occupied, "user collision"); + var manager = Manager(registry, []); + await Prepare(manager, launch); + var original = Assert.Single(((PakDir)manager.PakDir).ManagedPaks); + Assert.Equal("Unchained-Mods (1)_0_P.pak", original.PakFileName); + Assert.True(manager.EnableModRelease(ReleaseCoordinates.FromRelease(extra))); + await Prepare(manager, launch); + + var owned = ((PakDir)manager.PakDir).ManagedPaks.ToArray(); + var expectedOrder = manager.SortByDependencies(manager.GetEnabledAndDependencyReleases()) + .Select(ReleaseCoordinates.FromRelease).ToArray(); + Assert.Equal(expectedOrder, owned.OrderBy(pak => pak.Priority) + .Select(pak => pak.Coordinates)); + Assert.True(File.Exists(Path.Combine(paks, original.PakFileName))); + Assert.Equal(original.PakFileName, Assert.Single(owned, pak => pak.Coordinates == original.Coordinates).PakFileName); + Assert.Equal("user collision", File.ReadAllText(occupied)); + Assert.Equal(2, owned.Length); + Assert.All(owned, pak => Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig")))); + Assert.All(owned, pak => Assert.EndsWith($"_{pak.Priority}_P.pak", pak.PakFileName)); + + await Prepare(manager, launch); + Assert.Equal(owned, ((PakDir)manager.PakDir).ManagedPaks); + var inventory = JsonHelpers.Deserialize(File.ReadAllText(Path.Combine(paks, FilePaths.ManagedPakInventoryFileName))); + Assert.True(inventory.Success); + Assert.Equal(owned, inventory.Result!.ManagedPaks); + Assert.Equal("user collision", File.ReadAllText(occupied)); + }); + } + + [Fact] + public async Task FailedDuplicateRemovalPersistsOwnershipAndCanBeRetried() { + await InInstallation(async (registry, common, extra, launch) => { + var paks = Path.GetFullPath(FilePaths.PakDir); + var coords = ReleaseCoordinates.FromRelease(common); + var first = new ManagedPak(coords, "first.pak", 0); + var duplicate = new ManagedPak(coords, "duplicate.pak", 1); + foreach (var pak in new[] { first, duplicate }) + File.Copy(Path.Combine(_root, "payload.pak"), Path.Combine(paks, pak.PakFileName)); + // A directory at the signature path deterministically rejects File.Delete + // on both Windows and Linux, unlike a platform-dependent file lock. + var blocked = Path.Combine(paks, "duplicate.sig"); + Directory.CreateDirectory(blocked); + var manager = new ModManager(registry, new PakDir(paks, [first, duplicate]), []); + Assert.False((await manager.UpdateModsList()).HasErrors); + Assert.True((await new Chivalry2ModsInstaller(manager, new ConsoleDialogue()).PrepareLaunch(launch)).IsNone); + var persisted = JsonHelpers.Deserialize(File.ReadAllText(Path.Combine(paks, FilePaths.ManagedPakInventoryFileName))); + Assert.True(persisted.Success); + Assert.Equal(2, persisted.Result!.ManagedPaks.Count()); + Assert.True(File.Exists(Path.Combine(paks, first.PakFileName))); + Assert.True(File.Exists(Path.Combine(paks, duplicate.PakFileName))); + Directory.Delete(blocked); + var retry = Manager(registry, []); + await Prepare(retry, launch); + AssertOnlyCommonAndUnknown(retry, common); + }); + } + + private static async Task Prepare(ModManager manager, LaunchOptions launch) { + Assert.False((await manager.UpdateModsList()).HasErrors); + ServerLauncher.ValidateReleases(manager, manager.EnabledModReleaseCoordinates); + var dialogue = new ConsoleDialogue(); + Assert.True((await new Chivalry2ModsInstaller(manager, dialogue).PrepareLaunch(launch)).IsSome); + Assert.True((await SigPreparer.Create(manager.PakDir, dialogue).PrepareLaunch(LanguageExt.Unit.Default)).IsSome); + } + + private static void AssertOnlyCommonAndUnknown(ModManager manager, Release common) { + var pakDir = (PakDir)manager.PakDir; + var owned = Assert.Single(pakDir.ManagedPaks); + Assert.Equal(ReleaseCoordinates.FromRelease(common), owned.Coordinates); + Assert.EndsWith("_0_P.pak", owned.PakFileName); + Assert.Equal(3, Directory.GetFiles(pakDir.DirPath, "*.pak").Length); // base, common, unknown + Assert.Equal("user payload", File.ReadAllText(Path.Combine(pakDir.DirPath, "user.pak"))); + Assert.Equal("base payload", File.ReadAllText(Path.Combine(pakDir.DirPath, "pakchunk0-WindowsNoEditor.pak"))); + Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(pakDir.DirPath, owned.PakFileName), ".sig"))); + var inventory = JsonHelpers.Deserialize(File.ReadAllText(Path.Combine(pakDir.DirPath, FilePaths.ManagedPakInventoryFileName))); + Assert.True(inventory.Success); + Assert.Equal(owned, Assert.Single(inventory.Result!.ManagedPaks)); + } + + private async Task InInstallation(Func action) { + var request = CliLaunchOptions.Parse(["server", "--chivalry2-path", _root, "--platform", "Steam"]); + await ServerLauncher.InInstallationDirectoryAsync(request, async () => { + Directory.CreateDirectory(FilePaths.PakDir); + File.WriteAllText(Path.Combine(FilePaths.PakDir, "pakchunk0-WindowsNoEditor.pak"), "base payload"); + File.WriteAllText(Path.Combine(FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), "base signature"); + File.WriteAllText(Path.Combine(FilePaths.PakDir, "user.pak"), "user payload"); + var source = Path.Combine(_root, "payload.pak"); + File.WriteAllText(source, "managed payload"); + var hash = FileHelpers.Sha512(source).Match(Right: h => h, Left: e => throw new Exception(e.Message)); + Release Release(string org, string mod) => new("v1.0.0", hash, "test.pak", DateTime.UtcNow, + new ModInfo($"https://github.com/{org}/{mod}", mod, "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + var common = Release("Chiv2-Community", "Unchained-Mods"); + var extra = Release("org", "GuiOnlyMod"); + var registry = new LocalModRegistry(Path.Combine(_root, "registry")); + await registry.AddRelease(common, source); + await registry.AddRelease(extra, source); + await action(registry, common, extra, request.ToLaunchOptions()); + foreach (var path in Directory.EnumerateFiles(registry.RegistryPath, "*.pak", SearchOption.AllDirectories)) { + using (File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { } + } + return 0; + }); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/MapRotationTests.cs b/UnchainedLauncher.CLI.Tests/MapRotationTests.cs new file mode 100644 index 00000000..e5120c30 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/MapRotationTests.cs @@ -0,0 +1,179 @@ +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class MapRotationTests : IDisposable { + private readonly string _root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), "map-rotation-" + Guid.NewGuid().ToString("N")); + private const string Section = "/Script/TBL.TBLGameMode"; + + public MapRotationTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private CliCommandOptions Parse(string yaml, string command = "server") { + var path = Path.Combine(_root, "server.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\n" + yaml); + return CommandLineOptions.Parse([command, "--config-path", path]); + } + + [Fact] + public void MixedReferencesAndInlineEntriesPreserveOrderAndDuplicates() { + var options = Assert.IsType(Parse(""" + server: + map-rotation: + - courtyard + - map-name: FFA_Wardenglade + - courtyard + map-profiles: + courtyard: + map-name: FFA_Courtyard + """)); + Assert.Equal(["FFA_Courtyard", "FFA_Wardenglade", "FFA_Courtyard"], options.Settings.Rotation!.Maps); + Assert.Equal(0, options.Settings.Rotation.StartIndex); + Assert.Equal("FFA_Courtyard", options.ToServerLaunchOptions().Map); + } + + [Fact] + public void ProfilesAreSharedButServerRotationsAndStartingMapsAreIndependent() { + var group = Assert.IsType(Parse(""" + servers: + first: + map-rotation: [courtyard, {map-name: FFA_Wardenglade}] + starting-map: ffa_wardenglade + second: + game-port: 7778 + ping-port: 3076 + query-port: 7072 + rcon-port: 9002 + map-rotation: [courtyard, {map-name: FFA_Bazaar}] + map-profiles: + courtyard: {map-name: FFA_Courtyard} + """)); + var first = group.Servers[0].Options; + var second = group.Servers[1].Options; + Assert.Equal(1, first.Settings.Rotation!.StartIndex); + Assert.Equal(0, second.Settings.Rotation!.StartIndex); + Assert.Equal("FFA_Wardenglade", first.ToServerLaunchOptions().Map); + Assert.Equal("FFA_Courtyard", second.ToServerLaunchOptions().Map); + foreach (var server in group.Servers) { + var directory = Path.Combine(_root, server.Id); + ServerLauncher.InitializeServerProfile(server.Options, _ => directory); + } + var firstIni = File.ReadAllText(Path.Combine(_root, "first", "Game.ini")); + var secondIni = File.ReadAllText(Path.Combine(_root, "second", "Game.ini")); + Assert.Contains(".MapList=FFA_Wardenglade", firstIni); + Assert.DoesNotContain("FFA_Bazaar", firstIni); + Assert.Contains("MapListIndex=1", firstIni); + Assert.Contains(".MapList=FFA_Bazaar", secondIni); + Assert.DoesNotContain("FFA_Wardenglade", secondIni); + Assert.Contains("MapListIndex=0", secondIni); + } + + [Fact] + public void RotationPatchesOnlyMapListAndIndexAndDoesNotAccumulateOnRelaunch() { + var options = Assert.IsType(Parse(""" + server: + map-rotation: + - map-name: FFA_Wardenglade + - map-name: FFA_Wardenglade + - map-name: /Game/Maps/Custom.Custom + """)); + Assert.Equal("FFA_Wardenglade", options.ToServerLaunchOptions().Map); + var path = Path.Combine(_root, "Game.ini"); + File.WriteAllText(path, $"; keep comment\n[{Section}]\n.MapList=Old\n+MapList=Other\nMapListIndex=9\nServerName=KeepName\nCustom=KeepValue\n[/Other]\nUntouched=yes\n"); + options.Settings.Rotation!.Apply(_root); + var text = File.ReadAllText(path); + Assert.Contains("; keep comment", text); + Assert.Contains("ServerName=KeepName", text); + Assert.Contains("Custom=KeepValue", text); + Assert.Contains("Untouched=yes", text); + Assert.DoesNotContain("MapList=Old", text); + Assert.DoesNotContain("MapList=Other", text); + Assert.Contains("!MapList=ClearArray", text); + Assert.Equal(2, text.Split('\n').Count(line => line.TrimEnd('\r') == ".MapList=FFA_Wardenglade")); + Assert.Contains(".MapList=/Game/Maps/Custom.Custom", text); + Assert.Contains("MapListIndex=0", text); + options.Settings.Rotation.Apply(_root); + Assert.Equal(text, File.ReadAllText(path)); + } + + [Fact] + public void OmittedRotationPreservesExistingMapListAndDefaultStartup() { + var options = Assert.IsType(Parse("server: {}")); + Assert.Null(options.Settings.Rotation); + Assert.Equal("FFA_Courtyard", options.ToServerLaunchOptions().Map); + var path = Path.Combine(_root, "Game.ini"); + File.WriteAllText(path, $"[{Section}]\nMapList=KeepMap\nMapListIndex=7\n"); + ServerLauncher.InitializeServerProfile(options, _ => _root); + var text = File.ReadAllText(path); + Assert.Contains("MapList=KeepMap", text); + Assert.Contains("MapListIndex=7", text); + } + + [Theory] + [InlineData("server: {map-rotation: []}")] + [InlineData("server: {map-rotation: [null]}")] + [InlineData("server: {map-rotation: [~]}")] + [InlineData("server: {map-rotation: [[]]}")] + [InlineData("server: {map-rotation: [{}]}")] + [InlineData("server: {map-rotation: [{map-name: null}]}")] + [InlineData("server: {map-rotation: [{map-name: ''}]}")] + [InlineData("server: {map-rotation: [{map-name: 'FFA_Courtyard?mods=Turbo'}]}")] + [InlineData("server: {map-rotation: [{map-name: 'map with spaces'}]}")] + [InlineData("server: {map-rotation: [{map-name: '../outside'}]}")] + [InlineData("server: {map-rotation: [{map-name: FFA_Courtyard, typo: true}]}")] + [InlineData("server: {map-rotation: [{map-name: FFA_Courtyard, ini: {}}]}")] + [InlineData("server: {map-rotation: [{map-name: FFA_Courtyard, mod-actors: []}]}")] + [InlineData("map-profiles: {unused: {}}")] + [InlineData("map-profiles: {unused: null}")] + [InlineData("map-profiles: {'': {map-name: FFA_Courtyard}}")] + [InlineData("map-profiles: {one: {map-name: FFA_Courtyard}, one: {map-name: FFA_Bazaar}}")] + [InlineData("map-profiles: {one: {map-name: FFA_Courtyard, mod-actors: []}}")] + [InlineData("map-profiles: {one: {map-name: FFA_Courtyard, ini: {}}}")] + [InlineData("map-profiles: {one: other}")] + [InlineData("server: {starting-map: FFA_Bazaar, map-rotation: [{map-name: FFA_Courtyard}]}")] + public void InvalidRotationsAndProfilesFailBeforePreparation(string yaml) { + Assert.Throws(() => Parse(yaml)); + Assert.False(File.Exists(Path.Combine(_root, "Game.ini"))); + } + + [Theory] + [InlineData("missing")] + [InlineData("FFA_Courtyard")] + [InlineData("COURTYARD")] + public void MissingReferencesExplainHowToFixThem(string reference) { + var error = Assert.Throws(() => Parse($"server: {{map-rotation: [{reference}]}}\nmap-profiles: {{courtyard: {{map-name: FFA_Courtyard}}}}")); + Assert.Contains("Unknown map-profile", error.Message); + Assert.Contains("map-profiles", error.Message); + Assert.Contains("inline map-name", error.Message); + } + + [Theory] + [InlineData("MapList: [FFA_Courtyard]")] + [InlineData("maplist: null")] + [InlineData("MapList: {type: 'String[]', value: [FFA_Courtyard]}")] + [InlineData("MapList: {type: raw, value: anything}")] + [InlineData("'+MapList': anything")] + [InlineData("'!MapList': anything")] + public void IniMapListAlwaysDirectsUsersToRotation(string field) { + var error = Assert.Throws(() => Parse($"server:\n ini:\n Game:\n {Section}:\n {field}")); + Assert.Contains("MapList", error.Message); + Assert.Contains("map-rotation", error.Message); + } + + [Fact] + public void RotationIndexCannotBeOverriddenByIni() { + var error = Assert.Throws(() => Parse($"server:\n map-rotation: [{{map-name: FFA_Courtyard}}]\n ini:\n Game:\n {Section}:\n MapListIndex: 1")); + Assert.Contains("starting-map", error.Message); + } + + [Theory] + [InlineData("client")] + [InlineData("mod-query")] + [InlineData("dll-query")] + public void InactiveSchemasSkipMapProfilesAndRotation(string command) { + var path = Path.Combine(_root, "inactive.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\nmap-profiles: [invalid]\nservers: {one: {map-rotation: [missing]}}\n"); + var options = CommandLineOptions.Parse([command, .. command == "client" ? Array.Empty() : ["list"], "--config-path", path]); + Assert.NotNull(options); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/MemoryProgressTests.cs b/UnchainedLauncher.CLI.Tests/MemoryProgressTests.cs new file mode 100644 index 00000000..647bf616 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/MemoryProgressTests.cs @@ -0,0 +1,21 @@ +using UnchainedLauncher.Core.Utilities; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class MemoryProgressTests { + [Fact] + public async Task ConsoleProgressSupportsConcurrentTrackingAndReports() { + await Task.Run(() => { + Assert.Null(SynchronizationContext.Current); + var progress = new AccumulatedMemoryProgress(); + Parallel.For(0, 128, _ => { + var child = new MemoryProgress(); + progress.AlsoTrack(child); + for (var value = 1; value <= 100; value++) child.Report(value); + }); + Assert.Equal(128, progress.Progresses.Count); + Assert.Equal(100, progress.ProgressPercentage); + }); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ModQueryTests.cs b/UnchainedLauncher.CLI.Tests/ModQueryTests.cs new file mode 100644 index 00000000..17500277 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ModQueryTests.cs @@ -0,0 +1,208 @@ +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class ModQueryTests : IDisposable { + private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "mod-query-" + Guid.NewGuid().ToString("N")); + public ModQueryTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private ModQueryOptions Parse(params string[] args) => Assert.IsType(CommandLineOptions.Parse(["mod-query", .. args])); + + [Fact] + public void QueriesNeedNoGameInstallationAndAlwaysIncludeOfficialRegistry() { + var query = Parse("list"); + var official = Assert.IsType(Assert.Single(query.RegistryOptions.CreateRegistry().ModRegistries)); + Assert.Equal("Chiv2-Community", official.Organization); + Assert.Equal("C2ModRegistry", official.RepoName); + } + + [Fact] + public void QueryParsingUsesOneLibraryParseAndSupportsRepeatedRegistryOptions() { + var calls = 0; + var args = new List { "mod-query", "get", "org/mod", "--version=v1.0.0" }; + for (var i = 0; i < 12; i++) args.Add($"--add-registry=github:owner/repo{i}"); + var query = Assert.IsType(CommandLineOptions.Parse(args.ToArray(), (root, tokens, config) => { + calls++; + return root.Parse(tokens, config); + })); + Assert.Equal(1, calls); + Assert.Equal("v1.0.0", query.Version); + Assert.Equal("org", query.Mod!.Org); + Assert.Equal(13, query.RegistryOptions.CreateRegistry().ModRegistries.Count); + } + + [Fact] + public void RegistryYamlUsesRelativePathsAndIgnoresNonQuerySettings() { + var local = Path.Combine(_directory, "local"); + Directory.CreateDirectory(local); + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, """ + platform: invalid + chiv2-path: null + mod-setup: + registries: ['local:local'] + mods: {ignored: invalid-selection} + dlls: not-a-list + server: {name: one, name: two} + client: not-a-mapping + """); + var query = Parse("get", "org/mod", "--config-path", path, "--version", "v1.0.0", "--add-registry=github:ignored/repo"); + Assert.Equal("v1.0.0", query.Version); + Assert.Equal(local, Assert.Single(query.RegistryOptions.Registries).Location); + Assert.Equal(2, query.RegistryOptions.CreateRegistry().ModRegistries.Count); + File.WriteAllText(path, "mod-setup: {registries: ['local:local']}\nmod-setup: {}\n"); + Assert.Throws(() => Parse("list", "--config-path", path)); + } + + [Fact] + public void CoreRegistryConfigurationAndQueryOptionsWorkBeforeSubcommands() { + Directory.CreateDirectory(Path.Combine(_directory, "local")); + var path = Path.Combine(_directory, "registries.json"); + File.WriteAllText(path, JsonHelpers.Serialize(new AggregateModRegistryMetadata([ + new LocalModRegistryMetadata("local"), new GithubModRegistryMetadata("owner", "repo") + ]))); + var query = Parse("--add-registry", "config:" + path, "versions", "org/mod"); + var registry = query.RegistryOptions.CreateRegistry(); + Assert.Equal(3, registry.ModRegistries.Count); + Assert.Equal(Path.Combine(_directory, "local"), Assert.Single(registry.ModRegistries.OfType()).RegistryPath); + } + + [Fact] + public async Task QueriesHonorCancellation() { + var registry = CreateRegistry(); + using var stop = new CancellationTokenSource(); + stop.Cancel(); + await Assert.ThrowsAnyAsync(() => ModQuery.RunAsync(Parse("get", "org/mod"), registry, new StringWriter(), new StringWriter(), stop.Token)); + } + + [Theory] + [InlineData("mod-query")] + [InlineData("mod-query", "get")] + [InlineData("mod-query", "versions", "bad-id")] + [InlineData("mod-query", "get", "org/mod:v1")] + [InlineData("mod-query", "get", "org/mod", "--version=")] + [InlineData("mod-query", "get", "org/mod", "--version=a", "--version=b")] + [InlineData("mod-query", "list", "--version=v1")] + [InlineData("mod-query", "list", "--config-path")] + [InlineData("mod-query", "list", "--config-path=a", "--config-path=b")] + [InlineData("mod-query", "list", "--", "game-args")] + public void InvalidQueryInputIsRejected(params string[] args) => Assert.Throws(() => CommandLineOptions.Parse(args)); + + [Theory] + [InlineData("list")] + [InlineData("get")] + [InlineData("versions")] + public async Task HelpDoesNotAccessRegistries(string operation) { + var result = CommandLineOptions.Parse(["mod-query", operation, "--help"]); + Assert.True(result.Help); + Assert.Contains("--add-registry", result.HelpText); + Assert.Contains("--config-path", result.HelpText); + Assert.Equal(0, await Program.Main(["mod-query", operation, "--help"])); + } + + private LocalModRegistry CreateRegistry(bool stable = true) { + var info = new ModInfo("https://github.com/org/mod", "Display name", "Description", "https://example.test", null, [], ["Author"], + [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); + var manifest = new AssetCollections([], [], [new MapDto("/Game/Maps/Test", "map-hash", null, null, "FFA", "Test Map", null, null, null, null, null)], [], []); + var versions = stable ? new[] { "v1.0.0", "v2.0.0", "v3.0.0-beta.1" } : new[] { "v3.0.0-beta.1" }; + var releases = versions.Select((tag, index) => new Release(tag, "sha512-" + tag, "mod.pak", new DateTime(2026, 1, index + 1, 0, 0, 0, DateTimeKind.Utc), info, manifest, "Release notes")).ToList(); + var metadataPath = Path.Combine(_directory, "org", "mod"); + Directory.CreateDirectory(metadataPath); + File.WriteAllText(Path.Combine(metadataPath, "mod.json"), JsonHelpers.Serialize(new Mod(info, releases))); + foreach (var release in releases) { + var pakDirectory = Path.Combine(metadataPath, release.Tag); + Directory.CreateDirectory(pakDirectory); + File.WriteAllBytes(Path.Combine(pakDirectory, release.PakFileName), new byte[123]); + } + return new LocalModRegistry(_directory); + } + + [Fact] + public async Task ListShowsIdNameAndLatestStableWithoutChangingFiles() { + var registry = CreateRegistry(); + var files = Directory.GetFiles(_directory, "*", SearchOption.AllDirectories).Order().ToArray(); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(Parse("list"), registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("org/mod\tDisplay name\tv2.0.0", output.ToString()); + Assert.Equal(files, Directory.GetFiles(_directory, "*", SearchOption.AllDirectories).Order()); + } + + [Theory] + [InlineData(null, "v2.0.0")] + [InlineData("v1.0.0", "v1.0.0")] + [InlineData("v3.0.0-beta.1", "v3.0.0-beta.1")] + public async Task GetShowsSelectedReleaseAndManifestAndSizeWithoutDownloading(string? version, string expected) { + var registry = new AggregateModRegistry(CreateRegistry()); + var query = version is null ? Parse("get", "org/mod") : Parse("get", "org/mod", "--version", version); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(query, registry, output, new StringWriter(), CancellationToken.None)); + var text = output.ToString(); + Assert.Contains("Version: " + expected, text); + Assert.Contains("Pak hash (SHA-512): sha512-" + expected, text); + Assert.Contains("Pak file: mod.pak", text); + Assert.Contains("Size: 123 bytes", text); + Assert.Contains("Dependencies (1):", text); + Assert.Contains("org/dependency: >=1.0.0", text); + Assert.Contains("Maps (1):", text); + Assert.Contains("Test Map | /Game/Maps/Test", text); + Assert.Contains("Author", text); + Assert.Contains("Release notes", text); + } + + [Fact] + public async Task VersionsIncludePrereleasesAndUnavailableIdsFailClearly() { + var registry = CreateRegistry(); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(Parse("versions", "org/mod"), registry, output, new StringWriter(), CancellationToken.None)); + foreach (var version in new[] { "v1.0.0", "v2.0.0", "v3.0.0-beta.1" }) Assert.Contains(version, output.ToString()); + Assert.Equal(1, await ModQuery.RunAsync(Parse("get", "org/mod", "--version", "missing"), registry, new StringWriter(), new StringWriter(), CancellationToken.None)); + Assert.Equal(1, await ModQuery.RunAsync(Parse("get", "org/unknown"), registry, new StringWriter(), new StringWriter(), CancellationToken.None)); + } + + [Fact] + public async Task PrereleaseOnlyModCanBeQueriedAndMissingPakSizeRemainsUnknown() { + var registry = CreateRegistry(stable: false); + File.Delete(Path.Combine(_directory, "org", "mod", "v3.0.0-beta.1", "mod.pak")); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(Parse("get", "org/mod"), registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("Version: v3.0.0-beta.1", output.ToString()); + Assert.Contains("Size: unknown", output.ToString()); + } + + [Fact] + public async Task PartialCatalogKeepsRowsButReportsFailureAndDuplicateModsAreMerged() { + var local = CreateRegistry(); + var registry = new AggregateModRegistry(local, local, new UnavailableRegistry()); + var output = new StringWriter(); + var error = new StringWriter(); + Assert.Equal(1, await ModQuery.RunAsync(Parse("list"), registry, output, error, CancellationToken.None)); + Assert.Single(output.ToString().Split('\n').Where(line => line.StartsWith("org/mod\t"))); + Assert.Contains("registry unavailable", error.ToString()); + } + + [Fact] + public async Task EmptyReleaseCatalogIsHandledWithoutInventingAVersion() { + var registry = CreateRegistry(); + var path = Path.Combine(_directory, "org", "mod", "mod.json"); + var mod = await registry.GetMod(new ModIdentifier("org", "mod")).Match(value => value, _ => throw new Exception()); + File.WriteAllText(path, JsonHelpers.Serialize(mod with { Releases = [] })); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(Parse("list"), registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("org/mod\tDisplay name\tnone", output.ToString()); + Assert.Equal(1, await ModQuery.RunAsync(Parse("get", "org/mod"), registry, new StringWriter(), new StringWriter(), CancellationToken.None)); + } + + private sealed class UnavailableRegistry : IModRegistry { + public string Name => "unavailable"; + private static RegistryMetadataException Error => RegistryMetadataException.PackageListRetrieval("registry unavailable", LanguageExt.Prelude.None); + public Task GetAllMods() => Task.FromResult(new GetAllModsResult([Error], [])); + public LanguageExt.EitherAsync GetMod(ModIdentifier id) => LanguageExt.Prelude.LeftAsync(Error); + public LanguageExt.EitherAsync GetModRelease(ReleaseCoordinates coordinate) => LanguageExt.Prelude.LeftAsync(Error); + public LanguageExt.EitherAsync DownloadPak(ReleaseCoordinates coordinate, string output) => throw new InvalidOperationException("Queries must never download paks."); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs b/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs new file mode 100644 index 00000000..f416d407 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs @@ -0,0 +1,131 @@ +using LanguageExt.Common; +using System.Text; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using Xunit; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class NumericPakOrderingTests : IDisposable { + private readonly string _root = Path.Combine(Path.GetTempPath(), "numeric-paks-" + Guid.NewGuid().ToString("N")); + private int _downloads; + + public NumericPakOrderingTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, recursive: true); + + private ModInstallRequest Request(string module, string org = "org") => + new(new ReleaseCoordinates(org, module, "v1.0.0"), path => { + _downloads++; + var bytes = Encoding.UTF8.GetBytes(org + "/" + module); + return RightAsync(new FileWriter(path, new MemoryStream(bytes), bytes.Length)); + }); + + private static async Task Install(PakDir directory, params ModInstallRequest[] requests) { + await foreach (var result in directory.InstallModSet(requests, None)) + Assert.True(result.IsRight, result.ToString()); + } + + [Fact] + public async Task NumericOrdersFollowInputRatherThanAlphabeticalNamesPastTenMods() { + var directory = new PakDir(_root, []); + var requests = Enumerable.Range(0, 12).Select(i => Request($"Mod{11 - i}")).ToArray(); + await Install(directory, requests); + var installed = directory.ManagedPaks.OrderBy(pak => pak.Priority).ToArray(); + Assert.Equal(12, installed.Length); + for (var order = 0; order < installed.Length; order++) { + Assert.Equal(requests[order].Coordinates, installed[order].Coordinates); + Assert.Equal($"Mod{11 - order}_{order}_P.pak", installed[order].PakFileName); + } + await Install(directory, requests); + Assert.Equal(installed, directory.ManagedPaks.OrderBy(pak => pak.Priority)); + Assert.Equal(12, _downloads); + } + + [Fact] + public async Task ReorderingPreservesPayloadsAndSignaturesAndKeepsCollisionCountersBeforeOrder() { + var directory = new PakDir(_root, []); + var first = Request("First"); + var second = Request("Second"); + await Install(directory, first, second); + File.WriteAllText(Path.Combine(_root, "First_0_P.sig"), "first signature"); + var occupied = Path.Combine(_root, "First_1_P.pak"); + File.WriteAllText(occupied, "unmanaged"); + + await Install(directory, second, first); + var installed = directory.ManagedPaks.OrderBy(pak => pak.Priority).ToArray(); + Assert.Equal(["Second_0_P.pak", "First (1)_1_P.pak"], installed.Select(pak => pak.PakFileName)); + Assert.Equal("org/First", File.ReadAllText(Path.Combine(_root, installed[1].PakFileName))); + Assert.Equal("first signature", File.ReadAllText(Path.Combine(_root, "First (1)_1_P.sig"))); + Assert.False(File.Exists(Path.Combine(_root, "First_0_P.pak"))); + Assert.Equal("unmanaged", File.ReadAllText(occupied)); + await Install(directory, second, first); + Assert.Equal(installed, directory.ManagedPaks.OrderBy(pak => pak.Priority)); + Assert.Equal(2, _downloads); + + File.Delete(occupied); + await Install(directory, second, first); + Assert.Contains(directory.ManagedPaks, pak => pak.PakFileName == "First_1_P.pak"); + Assert.False(File.Exists(Path.Combine(_root, "First (1)_1_P.pak"))); + Assert.Equal("first signature", File.ReadAllText(Path.Combine(_root, "First_1_P.sig"))); + Assert.Equal(2, _downloads); + } + + [Theory] + [InlineData("q__-__Legacy_0_P.pak", false)] + [InlineData("q__-__Legacy.pak", false)] + [InlineData("q__-__Legacy_0_P.pak", true)] + [InlineData("q__-__Legacy.pak", true)] + public async Task LegacyManagedNamesMigrateWithoutDownloading(string legacyName, bool collision) { + var request = Request("Legacy"); + File.WriteAllText(Path.Combine(_root, legacyName), "legacy payload"); + File.WriteAllText(Path.ChangeExtension(Path.Combine(_root, legacyName), ".sig"), "legacy signature"); + if (collision) File.WriteAllText(Path.Combine(_root, "Legacy_0_P.pak"), "unmanaged"); + var directory = new PakDir(_root, [new ManagedPak(request.Coordinates, legacyName, 0)]); + await Install(directory, request); + var expected = collision ? "Legacy (1)_0_P.pak" : "Legacy_0_P.pak"; + Assert.Equal(expected, Assert.Single(directory.ManagedPaks).PakFileName); + Assert.Equal("legacy payload", File.ReadAllText(Path.Combine(_root, expected))); + Assert.Equal("legacy signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(_root, expected), ".sig"))); + if (collision) Assert.Equal("unmanaged", File.ReadAllText(Path.Combine(_root, "Legacy_0_P.pak"))); + Assert.False(File.Exists(Path.Combine(_root, legacyName))); + Assert.Equal(0, _downloads); + } + + [Fact] + public async Task DuplicateModuleNamesRetainOrganizationDisambiguation() { + var directory = new PakDir(_root, []); + await Install(directory, Request("Common", "one"), Request("Common", "two")); + Assert.Equal(["one_Common_0_P.pak", "two_Common_1_P.pak"], + directory.ManagedPaks.OrderBy(pak => pak.Priority).Select(pak => pak.PakFileName)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DownloadUsesCollisionSafeNumericNameForPayloadAndSignature(bool cached) { + File.WriteAllText(Path.Combine(_root, "Mod_0_P.pak"), "unknown payload"); + File.WriteAllText(Path.Combine(_root, "Mod (1)_0_P.sig"), "unknown signature"); + var signature = Path.Combine(_root, "base.sig"); + File.WriteAllText(signature, "base signature"); + var bytes = Encoding.UTF8.GetBytes("selected payload"); + var hash = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(bytes)); + string? writerPath = null; + var request = new ModInstallRequest(new ReleaseCoordinates("org", "Mod", "v1.0.0"), path => { + writerPath = path; + return RightAsync(new FileWriter(path, new MemoryStream(bytes), bytes.Length)); + }, hash); + var directory = new PakDir(_root, [], signature, cached ? Path.Combine(_root, "cache") : null); + + await Install(directory, request); + + Assert.Equal("Mod (2)_0_P.pak", Assert.Single(directory.ManagedPaks).PakFileName); + var selected = Path.Combine(_root, "Mod (2)_0_P.pak"); + if (!cached) Assert.Equal(selected, writerPath); + Assert.Equal("selected payload", File.ReadAllText(selected)); + Assert.Equal("base signature", File.ReadAllText(Path.ChangeExtension(selected, ".sig"))); + Assert.Equal("unknown payload", File.ReadAllText(Path.Combine(_root, "Mod_0_P.pak"))); + Assert.Equal("unknown signature", File.ReadAllText(Path.Combine(_root, "Mod (1)_0_P.sig"))); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs new file mode 100644 index 00000000..85722da9 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs @@ -0,0 +1,196 @@ +using LanguageExt; +using LanguageExt.Common; +using System.Security.Cryptography; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using Xunit; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class PakCacheTests : IDisposable { + private readonly string _root = Path.Combine(Path.GetTempPath(), "pak-cache-" + Guid.NewGuid().ToString("N")); + private static readonly byte[] Bytes = [1, 2, 3, 4]; + private static string Hash => Convert.ToHexString(SHA512.HashData(Bytes)).ToLowerInvariant(); + public PakCacheTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, recursive: true); + + private static ModInstallRequest Request(Func> writer, string? hash = null) => + new(new ReleaseCoordinates("org", "mod", "v1.0.0"), path => writer(path), hash ?? Hash); + + [Fact] + public async Task ConcurrentInstancesDownloadOnceAndUnlinkIndependently() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var downloads = 0; + var request = Request(path => { + Interlocked.Increment(ref downloads); + return RightAsync(new FileWriter(path, new MemoryStream(Bytes), Bytes.Length)); + }); + var first = Path.Combine(_root, "q__first.pak"); + var second = Path.Combine(_root, "r__second.pak"); + var results = await Task.WhenAll(cache.Install(request, first, None).ToEither(), cache.Install(request, second, None).ToEither()); + Assert.All(results, result => Assert.True(result.IsRight, result.ToString())); + Assert.Equal(1, downloads); + Assert.Equal(Bytes, File.ReadAllBytes(first)); + File.Delete(first); + Assert.Equal(Bytes, File.ReadAllBytes(second)); + Assert.Equal(Bytes, File.ReadAllBytes(Path.Combine(cache.Root, Hash, "mod.pak"))); + } + + [Fact] + public async Task WaitingForAnotherDownloaderCanBeCancelled() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var directory = Path.Combine(cache.Root, Hash); + Directory.CreateDirectory(directory); + using var held = new FileStream(Path.Combine(directory, ".lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + var request = Request(_ => throw new InvalidOperationException("Cancelled waiter must not download.")); + var result = await cache.Install(request, Path.Combine(_root, "mod.pak"), None, stop.Token).ToEither() + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(result.IsLeft); + Assert.False(File.Exists(Path.Combine(_root, "mod.pak"))); + } + + [Fact] + public async Task SecondInstanceCanLinkWhileFirstPakIsOpenWithoutDeleteSharing() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream(Bytes), Bytes.Length))); + var first = Path.Combine(_root, "first.pak"); + Assert.True((await cache.Install(request, first, None)).IsRight); + using var openPak = new FileStream(first, FileMode.Open, FileAccess.Read, FileShare.Read); + var second = Path.Combine(_root, "second.pak"); + var result = await cache.Install(request, second, None); + Assert.True(result.IsRight, result.ToString()); + Assert.Equal(Bytes, File.ReadAllBytes(second)); + } + + [Fact] + public async Task HashMismatchNeverPublishesCacheOrActiveFile() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream([9, 9]), 2))); + var destination = Path.Combine(_root, "mod.pak"); + Assert.True((await cache.Install(request, destination, None)).IsLeft); + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(Path.Combine(cache.Root, Hash, "mod.pak"))); + Assert.Empty(Directory.GetFiles(cache.Root, "*.tmp", SearchOption.AllDirectories)); + } + + [Fact] + public async Task UnknownTransportSizeStillRequiresAndAcceptsTheRegisteredHash() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream(Bytes), 0))); + var destination = Path.Combine(_root, "mod.pak"); + Assert.True((await cache.Install(request, destination, None)).IsRight); + Assert.Equal(Bytes, File.ReadAllBytes(destination)); + } + + [Fact] + public async Task TruncatedDownloadNeverPublishes() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream(Bytes), Bytes.Length + 1))); + Assert.True((await cache.Install(request, Path.Combine(_root, "mod.pak"), None)).IsLeft); + Assert.False(File.Exists(Path.Combine(cache.Root, Hash, "mod.pak"))); + } + + [Fact] + public async Task OccupiedActivePathIsNeverOverwritten() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream(Bytes), Bytes.Length))); + var destination = Path.Combine(_root, "user.pak"); + File.WriteAllBytes(destination, [9]); + Assert.True((await cache.Install(request, destination, None)).IsLeft); + Assert.Equal(new byte[] { 9 }, File.ReadAllBytes(destination)); + Assert.Empty(Directory.GetFiles(_root, "*.tmp", SearchOption.AllDirectories)); + } + + [Fact] + public async Task CorruptCacheFailsClosedWithoutRedownloadingOrPublishing() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var directory = Path.Combine(cache.Root, Hash); + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, "mod.pak"), [9]); + var request = Request(_ => throw new InvalidOperationException("Must not download over a cache object.")); + Assert.True((await cache.Install(request, Path.Combine(_root, "mod.pak"), None)).IsLeft); + Assert.Equal(new byte[] { 9 }, File.ReadAllBytes(Path.Combine(directory, "mod.pak"))); + } + + [Theory] + [InlineData("../escape")] + [InlineData("")] + [InlineData("abcd")] + public async Task InvalidHashIsRejectedBeforeDownloading(string hash) { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(_ => throw new InvalidOperationException("Must not download."), hash); + Assert.True((await cache.Install(request, Path.Combine(_root, "mod.pak"), None)).IsLeft); + } + + [Fact] + public void DefaultClientUsesGuiSharedProfileWhileCliCanSelectAnother() { + var client = CliLaunchOptions.Parse(["client", "--chivalry2-path", _root, "--platform", "Steam"]); + var separate = CliLaunchOptions.Parse(["client", "--chivalry2-path", _root, "--platform", "Steam", "--saved-dir-suffix", "Another"]); + Assert.Equal(ProfileAssetPaths.SharedSuffix, client.Shared.SavedDirSuffix); + Assert.Equal(new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins, client.InstancePaths.Plugins); + Assert.NotEqual(client.InstancePaths.Plugins, separate.InstancePaths.Plugins); + } + + [Fact] + public void CacheAndSignaturePathsSurviveMetadataRoundTrip() { + var directory = Path.Combine(_root, "paks"); + Directory.CreateDirectory(directory); + var cache = Path.Combine(_root, "cache"); + var signature = Path.Combine(_root, "base.sig"); + var registry = new AggregateModRegistry(System.Array.Empty()); + var manager = new ModManager(registry, new PakDir(directory, [], signature, cache), []); + var codec = new ModManagerCodec(registry); + var restored = codec.Deserialize(codec.Serialize(manager)); + Assert.True(restored.Success); + var paks = Assert.IsType(restored.Result!.PakDir); + Assert.Equal(cache, paks.CachePath); + Assert.Equal(signature, paks.DefaultSigPath); + } + + [Fact] + public void ProfileAssetsAndSharedCachesUseLocalAppData() { + var first = new ProfileAssetPaths("Host-one", _root); + var second = new ProfileAssetPaths("Host-two", _root); + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "Paks"), first.Paks); + Assert.Equal(first.Paks, new ServerInstancePaths(first).Paks); + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "unchained", "plugins"), first.Plugins); + Assert.NotEqual(first.Paks, second.Paks); + Assert.Equal(first.PakCache, second.PakCache); + Assert.Equal(first.DllCache, second.DllCache); + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_unchained", "Paks"), + new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, _root).Paks); + } + + [Fact] + public async Task InstanceUsesCorePathsSignatureCacheCancellationAndExclusiveLease() { + var assets = new ProfileAssetPaths("Host-one", _root); + var instance = new ServerInstancePaths(assets); + var installation = Path.Combine(_root, "game"); + using var stop = new CancellationTokenSource(); + var paks = instance.CreatePakDir(installation, stop.Token); + Assert.Equal(assets.Plugins, instance.Plugins); + Assert.Equal(assets.PakCache, paks.CachePath); + Assert.Equal(Path.Combine(installation, FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), paks.DefaultSigPath); + using (instance.AcquireLease()) { + Assert.Throws(() => instance.AcquireLease()); + } + using var lease = instance.AcquireLease(); + stop.Cancel(); + var results = new List>(); + await foreach (var result in paks.InstallModSet([Request(_ => throw new Exception("Cancelled install must not download."))], None)) + results.Add(result); + Assert.True(Assert.Single(results).IsLeft); + Assert.Empty(Directory.GetFiles(assets.Paks, "*.pak")); + } + + [Theory] + [InlineData("---")] + [InlineData("../outside")] + [InlineData("bad suffix")] + [InlineData("")] + public void CoreProfileValidationRejectsUnsafeOrEmptyNormalizedSuffix(string suffix) => + Assert.Throws(() => new ProfileAssetPaths(suffix, _root)); +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs new file mode 100644 index 00000000..e790c683 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -0,0 +1,303 @@ +using LanguageExt; +using LanguageExt.Common; +using System.Diagnostics; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +[Collection("Installation directory")] +public class RegistryAndReleaseTests : IDisposable { + private readonly string _directory = Path.Combine( + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "cli-tests-" + Guid.NewGuid().ToString("N")); + + public RegistryAndReleaseTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Fact] + public void ConfigurationSupportsNestedAggregatesAndRelativeLocalPaths() { + var cwd = Directory.GetCurrentDirectory(); + var local = Path.Combine(_directory, "local"); + Directory.CreateDirectory(local); + var path = Path.Combine(_directory, "registries.json"); + var metadata = new AggregateModRegistryMetadata([ + new GithubModRegistryMetadata("owner", "repo"), + new AggregateModRegistryMetadata([new LocalModRegistryMetadata("local")]) + ]); + File.WriteAllText(path, JsonHelpers.Serialize(metadata)); + var registry = Assert.IsType(RegistrySpec.Parse("config:" + path).Create()); + // Core flattens nested aggregates. + var localRegistry = Assert.Single(registry.ModRegistries.OfType()); + Assert.Equal(local, localRegistry.RegistryPath); + Assert.Single(registry.ModRegistries.OfType()); + Assert.Equal(cwd, Directory.GetCurrentDirectory()); + } + + [Fact] + public void MissingLocalRegistryDoesNotCreateADirectory() { + var path = Path.Combine(_directory, "missing"); + Assert.Throws(() => RegistrySpec.Parse("local:" + path).Create()); + Assert.False(Directory.Exists(path)); + } + + [Fact] + public void MissingNestedLocalRegistryDoesNotCreateADirectoryOrChangeCwd() { + var cwd = Directory.GetCurrentDirectory(); + var path = Path.Combine(_directory, "registries.json"); + var metadata = new AggregateModRegistryMetadata([ + new AggregateModRegistryMetadata([new LocalModRegistryMetadata("missing")]) + ]); + File.WriteAllText(path, JsonHelpers.Serialize(metadata)); + Assert.Throws(() => RegistrySpec.Parse("config:" + path).Create()); + Assert.False(Directory.Exists(Path.Combine(_directory, "missing"))); + Assert.Equal(cwd, Directory.GetCurrentDirectory()); + } + + [Fact] + public void InvalidConfigurationFailsClearly() { + var path = Path.Combine(_directory, "bad.json"); + File.WriteAllText(path, "{ broken"); + Assert.Throws(() => RegistrySpec.Parse("config:" + path).Create()); + } + + private static Mod Mod(string org, string id, string tag, params Dependency[] dependencies) { + var info = new ModInfo($"https://github.com/{org}/{id}", id, "test", null, null, [], [], dependencies.ToList()); + var release = new Release(tag, "hash", "test.pak", DateTime.UtcNow, info, + new AssetCollections([], [], [], [], []), null); + return new Mod(info, [release]); + } + + private static Mod Common() => Mod("Chiv2-Community", "Unchained-Mods", "v1.0.0"); + + private ModManager Manager(ReleaseCoordinates[] requested, params Mod[] mods) => + new(new AggregateModRegistry(), new PakDir(_directory, []), requested, mods); + + [Fact] + public void ValidRequestedReleaseAndCommonDependencyPass() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.2.3"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v1.2.3")); + ServerLauncher.ValidateReleases(manager, [coordinate]); + } + + [Fact] + public void MissingRequestedReleaseFailsInsteadOfSilentlyIgnoringIt() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.2.3"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v2.0.0")); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void MissingCommonReleaseFails() { + Assert.Throws(() => ServerLauncher.ValidateReleases(Manager([]), [])); + } + + [Fact] + public void MissingDependencyFailsInsteadOfSilentlyDroppingIt() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.0.0"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v1.0.0", + new Dependency("https://github.com/org/missing", "*"))); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void ConflictBetweenPinnedCommonAndLatestAutomaticCommonFails() { + var common = Common(); + var older = Mod("Chiv2-Community", "Unchained-Mods", "v0.9.0").Releases.Single(); + common.Releases.Add(older); + var coordinate = ReleaseCoordinates.FromRelease(older); + var manager = Manager([coordinate], common); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void PinnedReleaseDependencyMustActuallyBeInTheInstallSet() { + var older = Mod("org", "mod", "v1.0.0", new Dependency("https://github.com/org/old-dependency", "*")); + older.Releases.Add(Mod("org", "mod", "v2.0.0").Releases.Single()); + var coordinate = new ReleaseCoordinates("org", "mod", "v1.0.0"); + var manager = Manager([coordinate], Common(), older, Mod("org", "old-dependency", "v1.0.0")); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Theory] + [InlineData("cycle")] + [InlineData("self")] + [InlineData("out-of-order")] + [InlineData("valid")] + public void PinnedReleaseEdgesMustPrecedeDependentsDespiteDifferentLatestMetadata(string graph) { + var a = Mod("org", "a", "v1.0.0"); + var b = Mod("org", "b", "v1.0.0"); + a.Releases.Add(Mod("org", "a", "v2.0.0").Releases.Single()); + b.Releases.Add(Mod("org", "b", "v2.0.0").Releases.Single()); + ReleaseCoordinates[] requested = [new("org", "a", "v1.0.0"), new("org", "b", "v1.0.0")]; + var manager = Manager(requested, Common(), a, b); + var sorted = manager.SortByDependencies(manager.GetEnabledAndDependencyReleases()).ToArray(); + var pinned = sorted.Where(release => release.Info.Organization == "org").ToArray(); + // Hash-set iteration is unspecified; construct an edge relative to the actual latest-metadata order. + void Depends(Release dependent, Release dependency) => dependent.Info.Dependencies!.Add(new Dependency(dependency.Info.RepoUrl, "*")); + if (graph == "self") Depends(pinned[0], pinned[0]); + else if (graph == "valid") Depends(pinned[1], pinned[0]); + else { + Depends(pinned[0], pinned[1]); + if (graph == "cycle") Depends(pinned[1], pinned[0]); + } + Assert.Equal(sorted, manager.SortByDependencies(manager.GetEnabledAndDependencyReleases()).ToArray()); + File.WriteAllText(Path.Combine(_directory, "unknown.pak"), "untouched"); + if (graph == "valid") ServerLauncher.ValidateReleases(manager, requested); + else Assert.Throws(() => ServerLauncher.ValidateReleases(manager, requested)); + Assert.Equal("untouched", File.ReadAllText(Path.Combine(_directory, "unknown.pak"))); + Assert.Single(Directory.GetFiles(_directory)); + Assert.Empty(Assert.IsType(manager.PakDir).ManagedPaks); + } + + [Fact] + public async Task CoreModPreparationAndInventoryRoundTripWorkInAConsoleHost() { + await Task.Run(async () => { + Assert.Null(SynchronizationContext.Current); + var paks = Path.Combine(_directory, "paks"); + Directory.CreateDirectory(paks); + File.WriteAllText(Path.Combine(paks, "pakchunk0-WindowsNoEditor.sig"), "test vanilla signature"); + var source = Path.Combine(_directory, "source.pak"); + File.WriteAllText(source, "test pak payload"); + var registry = new LocalModRegistry(Path.Combine(_directory, "registry")); + var release = Common().Releases.Single() with { + ReleaseHash = FileHelpers.Sha512(source).Match(Right: hash => hash, Left: error => throw new Exception(error.Message)) + }; + await registry.AddRelease(release, source); + var manager = new ModManager(registry, new PakDir(paks, []), []); + Assert.False((await manager.UpdateModsList()).HasErrors); + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]).ToLaunchOptions(); + var dialogue = new ConsoleDialogue(); + Assert.True((await new Chivalry2ModsInstaller(manager, dialogue).PrepareLaunch(options)).IsSome); + var registryPak = Path.Combine(registry.RegistryPath, release.Info.Organization, release.Info.RepoName, + release.Tag, release.PakFileName); + using (File.Open(registryPak, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { } + Assert.True((await SigPreparer.Create(manager.PakDir, dialogue).PrepareLaunch(LanguageExt.Unit.Default)).IsSome); + var installed = Assert.Single(((PakDir)manager.PakDir).ManagedPaks); + Assert.Equal("test pak payload", File.ReadAllText(Path.Combine(paks, installed.PakFileName))); + Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, installed.PakFileName), ".sig"))); + + var codec = new ModManagerCodec(registry); + var restored = codec.Deserialize(codec.Serialize(manager)); + Assert.True(restored.Success); + Assert.True((await new Chivalry2ModsInstaller(restored.Result!, dialogue).PrepareLaunch(options)).IsSome); + Assert.Single(((PakDir)restored.Result!.PakDir).ManagedPaks); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDirectory(bool useYaml) { + var callerDirectory = Directory.GetCurrentDirectory(); + var install = Path.Combine(_directory, "arbitrary game installation"); + var paks = Path.Combine(install, FilePaths.PakDir); + Directory.CreateDirectory(paks); + File.WriteAllText(Path.Combine(paks, "pakchunk0-WindowsNoEditor.sig"), "vanilla signature"); + var source = Path.Combine(_directory, "source.pak"); + File.WriteAllText(source, "test payload"); + var hash = FileHelpers.Sha512(source).Match(Right: value => value, Left: error => throw new Exception(error.Message)); + var args = new List { "server", "--chivalry2-path", install, "--platform", "Steam" }; + var releases = new List(); + var actors = Enumerable.Range(0, 12).Select(i => $"/Game/Mods/Actor{i}.Actor{i}_C").ToArray(); + for (var i = 0; i < 12; i++) { + var registryPath = Path.Combine(_directory, "registry " + i); + var registry = new LocalModRegistry(registryPath); + var release = Mod("org", "mod" + i, "v1.0.0").Releases.Single() with { ReleaseHash = hash }; + releases.Add(release); + await registry.AddRelease(release, source); + if (i == 0) await registry.AddRelease(Common().Releases.Single() with { ReleaseHash = hash }, source); + args.AddRange(["--add-registry", "local:" + registryPath, "--mod", $"org/mod{i}:v1.0.0", "--server-mod", actors[i]]); + } + var options = CliLaunchOptions.Parse(args.ToArray()); + if (useYaml) { + string Quote(string value) => "'" + value.Replace("'", "''") + "'"; + var yaml = new List { "chiv2-path: " + Quote(install), "platform: Steam", "server:", " mod-setup:", " registries:" }; + yaml.AddRange(options.Shared.Registries.Select(r => " - " + Quote(r.Kind + ":" + r.Location))); + yaml.Add(" mods:"); + yaml.AddRange(options.Shared.Mods.Select(m => " - " + Quote($"{m.Org}/{m.ModuleName}:{m.Version}"))); + yaml.Add(" mod-actors:"); + yaml.AddRange(actors.Select(actor => " - " + Quote(actor))); + var config = Path.Combine(_directory, "launch.yaml"); + File.WriteAllLines(config, yaml); + options = CliLaunchOptions.Parse(["server", "--config-path", config]); + } + var aggregate = ServerLauncher.CreateRegistry(options); + var official = Assert.Single(aggregate.ModRegistries.OfType()); + Assert.Equal(("Chiv2-Community", "C2ModRegistry"), (official.Organization, official.RepoName)); + Assert.Equal(13, aggregate.ModRegistries.Count()); + // Keep metadata deterministic/offline; assert the official production entry above, + // then use all real local registries created by the production factory for installs. + var offline = new AggregateModRegistry(aggregate.ModRegistries.OfType().Cast().ToArray()); + var manager = new ModManager(offline, new PakDir(paks, []), options.Shared.Mods); + Assert.False((await manager.UpdateModsList()).HasErrors); + ServerLauncher.ValidateReleases(manager, options.Shared.Mods); + var process = new CapturingProcessLauncher(); + await ServerLauncher.InInstallationDirectoryAsync(options, async () => { + Assert.Equal(install, Directory.GetCurrentDirectory()); + var dialogue = new ConsoleDialogue(); + var installer = new Chivalry2ModsInstaller(manager, dialogue); + var signing = SigPreparer.Create(manager.PakDir, dialogue); + var preparer = Chivalry2LaunchPreparer.Create(async launch => { + var prepared = await installer.PrepareLaunch(launch); + Assert.True(prepared.IsSome); + Assert.True((await signing.PrepareLaunch(Unit.Default)).IsSome); + return prepared; + }); + // Capture at the OS process boundary; retain Core preparation/argument rendering. + var launcher = new UnchainedChivalry2Launcher(preparer, process, options.Shared.Chivalry2Path, new NullInjector()); + return await launcher.Launch(options.ToLaunchOptions()); + }); + Assert.Equal(callerDirectory, Directory.GetCurrentDirectory()); + Assert.Equal(Path.Combine(install, FilePaths.BinDir), process.WorkingDirectory); + Assert.Contains("--server-mods " + string.Join(",", actors), process.Arguments); + var installed = ((PakDir)manager.PakDir).ManagedPaks.ToArray(); + Assert.Equal(13, installed.Length); + foreach (var release in releases) { + Assert.Contains(installed, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(release)); + } + foreach (var pak in installed) { + Assert.Equal("test payload", File.ReadAllText(Path.Combine(paks, pak.PakFileName))); + Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig"))); + } + foreach (var registry in offline.ModRegistries.Cast()) { + foreach (var path in Directory.EnumerateFiles(registry.RegistryPath, "*.pak", SearchOption.AllDirectories)) { + using (File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { } + } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProductionDirectoryScopeRestoresCallerAfterFailureOrCancellation(bool cancel) { + var callerDirectory = Directory.GetCurrentDirectory(); + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]); + await Assert.ThrowsAnyAsync(() => ServerLauncher.InInstallationDirectoryAsync(options, () => { + Assert.Equal(_directory, Directory.GetCurrentDirectory()); + if (cancel) throw new OperationCanceledException(); + throw new IOException("Preparation failed."); + })); + Assert.Equal(callerDirectory, Directory.GetCurrentDirectory()); + } + + private sealed class CapturingProcessLauncher : IProcessLauncher { + public string Executable => "test-game.exe"; + public string WorkingDirectory { get; private set; } = ""; + public string Arguments { get; private set; } = ""; + public Either Launch(string workingDirectory, string args) { + WorkingDirectory = workingDirectory; + Arguments = args; + return new LaunchFailed(Executable, args, Error.New("Stopped at the OS boundary.")); + } + } +} + +[CollectionDefinition("Installation directory", DisableParallelization = true)] +public class InstallationDirectoryCollection { } \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs new file mode 100644 index 00000000..01bf64f3 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -0,0 +1,283 @@ +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Server.A2S; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +[Collection("Installation directory")] +public sealed class ServerGroupTests : IDisposable { + private readonly string _root = Path.Combine(System.Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), + "server-group-" + Guid.NewGuid().ToString("N")); + + private const string Servers = """ + servers: + courtyard: + name: Courtyard + starting-map: FFA_Courtyard + game-port: 7000 + ping-port: 7001 + query-port: 7002 + rcon-port: 7003 + arena: + name: Arena + starting-map: TO_Stronghold + game-port: 7004 + ping-port: 7005 + query-port: 7006 + rcon-port: 7007 + """; + + private CliCommandOptions Parse(string body, string command = "server", params string[] extra) { + Directory.CreateDirectory(_root); + var path = Path.Combine(_root, "servers.yaml"); + File.WriteAllText(path, "chiv2-path: ./game\nplatform: Steam\nno-tui: true\n" + body); + return CommandLineOptions.Parse([command, "--config-path", path, .. extra]); + } + + private ServerGroupOptions Group() { + var group = Assert.IsType(Parse(Servers)); + foreach (var entry in group.Servers) + entry.Options.Shared.Assets = new UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths(entry.Options.Shared.SavedDirSuffix, _root); + return group; + } + public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, true); } + + [Fact] + public void DictionaryPreservesOrderSharedSettingsAndIsolatedProfiles() { + var group = Group(); + Assert.True(group.NoTui); + Assert.Equal(["courtyard", "arena"], group.Servers.Select(entry => entry.Id)); + Assert.Equal(["courtyard", "arena"], group.Servers.Select(entry => entry.Options.Shared.SavedDirSuffix)); + Assert.All(group.Servers, entry => Assert.Equal(Path.Combine(_root, "game"), entry.Options.Shared.Chivalry2Path)); + Assert.Equal(["Courtyard", "Arena"], group.Servers.Select(entry => entry.Options.Settings.Name)); + Assert.Equal(["FFA_Courtyard", "TO_Stronghold"], group.Servers.Select(entry => entry.Options.ToServerLaunchOptions().Map)); + Assert.Equal(7006, group.Servers[1].Options.ToServerLaunchOptions().QueryPort); + Assert.Equal(2, group.Servers.Select(entry => entry.Options.InstancePaths.Paks).Distinct().Count()); + Assert.Equal(2, group.Servers.Select(entry => entry.Options.InstancePaths.Plugins).Distinct().Count()); + } + + [Fact] + public void PerServerProfilesAndArgumentsRemainIndependent() { + var group = Assert.IsType(Parse(Servers.Replace("name: Arena", "name: Arena\n saved-dir-suffix: ArenaProfile\n game-args: [arena-only]") + + "\ngame-args: [common]")); + Assert.Equal("ArenaProfile", group.Servers[1].Options.Shared.SavedDirSuffix); + Assert.Equal(["common"], group.Servers[0].Options.GameArguments); + Assert.Equal(["common", "arena-only"], group.Servers[1].Options.GameArguments); + } + + [Theory] + [InlineData("servers: {}")] + [InlineData("servers: {one: null}")] + [InlineData("server: {}\nservers: {one: {}}")] + [InlineData("servers: {'../one': {}}")] + [InlineData("servers: {one: {}, ONE: {}}")] + [InlineData("servers: {one: {}, two: {}}")] + [InlineData("servers: {one: {game-port: 0}}")] + [InlineData("servers: {one: {map: TO_Test}}")] + [InlineData("servers: {one: {saved-dir-suffix: '../outside'}}")] + [InlineData("servers: {one: {unsafe-very-dangerous-load-all-dlls: true}}")] + [InlineData("mod-setup: {mods: []}\nservers: {one: {}}")] + [InlineData("saved-dir-suffix: Host\nservers: {one: {}}")] + public void InvalidGroupsFailBeforeLaunch(string yaml) => Assert.Throws(() => Parse(yaml)); + + [Fact] + public void PerServerModsDllsRegistriesAndIniDoNotLeakAcrossServers() { + var group = Assert.IsType(Parse(Servers + .Replace("name: Courtyard", "name: Courtyard\n mod-setup: {registries: ['local:one'], mods: ['org/one:v1'], dlls: ['org/plugin:v1']}\n ini: {Game: {'/Script/TBL.TBLGameMode': {BotBackfillEnabled: true}}}") + .Replace("name: Arena", "name: Arena\n mod-setup: {registries: ['local:two'], mods: ['org/two:v2'], dlls: []}"))); + var first = group.Servers[0].Options; + var second = group.Servers[1].Options; + Assert.Equal("one", Assert.Single(first.Shared.Mods).ModuleName); + Assert.Equal("two", Assert.Single(second.Shared.Mods).ModuleName); + Assert.Single(first.Shared.Dlls); + Assert.Empty(second.Shared.Dlls); + Assert.Equal(Path.Combine(_root, "one"), Assert.Single(first.Shared.Registries).Location); + Assert.Equal(Path.Combine(_root, "two"), Assert.Single(second.Shared.Registries).Location); + Assert.True(first.Ini.Contains("Game", "/Script/TBL.TBLGameMode", "BotBackfillEnabled")); + Assert.False(second.Ini.Contains("Game", "/Script/TBL.TBLGameMode", "BotBackfillEnabled")); + } + + [Fact] + public void DuplicatePortsAndCaseInsensitiveProfilesAreRejected() { + Assert.Throws(() => Parse(Servers.Replace("7007", "7000"))); + Assert.Throws(() => Parse(Servers.Replace("name: Courtyard", "name: Courtyard\n saved-dir-suffix: SAME") + .Replace("name: Arena", "name: Arena\n saved-dir-suffix: same"))); + } + + [Fact] + public void ProfilesThatCollideAfterGameHyphenRemovalAreRejected() { + Assert.Throws(() => Parse(Servers.Replace("name: Courtyard", "name: Courtyard\n saved-dir-suffix: Host-one") + .Replace("name: Arena", "name: Arena\n saved-dir-suffix: Hostone"))); + } + + [Fact] + public void InstanceLeasePreventsConcurrentReuseAndCanBeReacquiredAfterRelease() { + var paths = Group().Servers[0].Options.InstancePaths; + using (paths.AcquireLease()) Assert.Throws(() => paths.AcquireLease()); + using (paths.AcquireLease()) Assert.True(Directory.Exists(paths.Root)); + } + + [Fact] + public void NativePakDirectoriesDoNotAddPluginArguments() { + Assert.All(Group().Servers, entry => + Assert.DoesNotContain("--additional-pak-dir", entry.Options.ToLaunchOptions().LaunchArgs)); + } + + [Fact] + public void CliOverridesAreIgnoredAndUnsafeAuthorizationAppliesToEveryServer() { + var group = Assert.IsType(Parse(Servers, "server", "--name", "ignored", "--platform", "invalid", + "--unsafe-very-dangerous-load-all-dlls")); + Assert.All(group.Servers, entry => Assert.True(entry.Options.Shared.UnsafeLoadAllDlls)); + Assert.Equal("Courtyard", group.Servers[0].Options.Settings.Name); + Assert.All(Group().Servers, entry => Assert.False(entry.Options.Shared.UnsafeLoadAllDlls)); + } + + [Fact] + public void ClientAndRegistryQueriesSkipInactiveServersSchema() { + Directory.CreateDirectory(_root); + var path = Path.Combine(_root, "query.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\nservers: [invalid, server, schema]\nmod-setup: {registries: []}"); + Assert.IsType(CommandLineOptions.Parse(["client", "--config-path", path])); + Assert.IsType(CommandLineOptions.Parse(["mod-query", "list", "--config-path", path])); + } + + private sealed class PendingQuery : IA2S { + public Task InfoAsync() => new TaskCompletionSource().Task; + } + + private sealed class FailingQuery : IA2S { + public Task InfoAsync() => Task.FromException(new TimeoutException()); + } + + private static Process Child(int? exitCode = null) { + var info = new ProcessStartInfo(OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh") { + UseShellExecute = false, + CreateNoWindow = true + }; + info.ArgumentList.Add(OperatingSystem.IsWindows() ? "/c" : "-c"); + info.ArgumentList.Add(exitCode.HasValue ? $"exit {exitCode}" : OperatingSystem.IsWindows() ? "ping -n 30 127.0.0.1 > nul" : "sleep 30"); + return Process.Start(info)!; + } + + [Fact] + public async Task DashboardRetainsEachConfigurationAfterLaunchAndQueryInitialization() { + var group = Group(); + using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var output = new StringWriter(); + var original = Console.Out; + var launches = 0; + var queries = 0; + try { + Console.SetOut(output); + Assert.Equal(0, await ServerGroupRunner.RunAsync(group, stop, (options, _) => { + options.Settings.Name = $"prepared-{++launches}"; + return Task.FromResult(Child()); + }, options => { + Assert.Equal(2, launches); + options.Settings.Name += $"-query-{++queries}"; + if (queries == 2) stop.CancelAfter(250); + return new PendingQuery(); + }).WaitAsync(TimeSpan.FromSeconds(15))); + } + finally { Console.SetOut(original); } + var text = output.ToString(); + Assert.Contains("[courtyard] Starting | prepared-1-query-1 | PID", text); + Assert.Contains("[arena] Starting | prepared-2-query-2 | PID", text); + Assert.Contains("Game Port 7000 | Query Port 7002 | Beacon Port 7001 | RCON Port 7003", text); + Assert.Contains("Game Port 7004 | Query Port 7006 | Beacon Port 7005 | RCON Port 7007", text); + Assert.Equal(2, queries); + } + + [Fact] + public async Task StartupFailureStopsAlreadyLaunchedServers() { + using var stop = new CancellationTokenSource(); + Task? exited = null; + var calls = 0; + var error = await Assert.ThrowsAsync(() => ServerGroupRunner.RunAsync(Group(), stop, + (_, _) => { + if (++calls == 2) throw new InvalidOperationException("launch failed"); + var child = Child(); + exited = child.WaitForExitAsync(); + return Task.FromResult(child); + }, _ => new PendingQuery())); + Assert.Equal("launch failed", error.Message); + await exited!.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(2, calls); + } + + [Fact] + public async Task GroupCancellationStopsEveryServerAndInterruptsPendingQueries() { + using var stop = new CancellationTokenSource(); + var exits = new List(); + var order = new List(); + Assert.Equal(0, await ServerGroupRunner.RunAsync(Group(), stop, async (options, _) => { + order.Add(options.Settings.Name); + var child = Child(); + exits.Add(child.WaitForExitAsync()); + if (exits.Count == 2) stop.CancelAfter(250); + await Task.Yield(); + return child; + }, _ => new PendingQuery()).WaitAsync(TimeSpan.FromSeconds(10))); + Assert.Equal(["Courtyard", "Arena"], order); + await Task.WhenAll(exits).WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task NaturalExitsReturnFailureWithoutStoppingOtherServersEarly() { + using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var calls = 0; + Assert.Equal(7, await ServerGroupRunner.RunAsync(Group(), stop, + (_, _) => Task.FromResult(Child(++calls == 1 ? 7 : 0)), _ => new FailingQuery())); + Assert.Equal(2, calls); + Assert.False(stop.IsCancellationRequested); + } + + [Fact] + public async Task ExitAtMonitorInitializationDoesNotStopSurvivingSibling() { + using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + Process? first = null; + Process? survivor = null; + Task? survivorExit = null; + var calls = 0; + var queriedSurvivor = false; + Assert.Equal(7, await ServerGroupRunner.RunAsync(Group(), stop, (_, _) => { + if (++calls == 1) { + var info = new ProcessStartInfo(OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh") { + UseShellExecute = false, + RedirectStandardInput = true, + ArgumentList = { OperatingSystem.IsWindows() ? "/c" : "-c", + OperatingSystem.IsWindows() ? "set /p ignored= & exit 7" : "read ignored; exit 7" } + }; + first = Process.Start(info)!; + return Task.FromResult(first); + } + survivor = Child(); + survivorExit = survivor.WaitForExitAsync(); + return Task.FromResult(survivor); + }, _ => { + if (!first!.HasExited) { + // Release the real child exactly when group monitoring initializes. + first.StandardInput.WriteLine("exit"); + first.StandardInput.Flush(); + Assert.True(first.WaitForExit(5000)); + return new FailingQuery(); + } + return new CallbackQuery(() => { + Assert.NotNull(survivor); + Assert.False(survivor!.HasExited); + queriedSurvivor = true; + survivor.Kill(entireProcessTree: true); + }); + })); + Assert.True(queriedSurvivor); + await survivorExit!.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(stop.IsCancellationRequested); + } + + private sealed class CallbackQuery(Action callback) : IA2S { + public Task InfoAsync() { + callback(); + return Task.FromException(new TimeoutException()); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs new file mode 100644 index 00000000..29e81558 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs @@ -0,0 +1,195 @@ +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Server.A2S; +using UnchainedLauncher.Core.Utilities; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class ServerMonitorTests { + [Fact] + public async Task AlreadyExitedProcessRecordsNormalExitWithoutQuerying() { + var info = new ProcessStartInfo(OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh") { + UseShellExecute = false, + ArgumentList = { OperatingSystem.IsWindows() ? "/c" : "-c", "exit 7" } + }; + using var process = Process.Start(info)!; + await process.WaitForExitAsync(); + var monitor = new ServerMonitor(process, new PendingQuery()); + await monitor.RunAsync((_, _) => Assert.True(false, "Exited processes must not render samples"), CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Empty(monitor.History.Samples); + Assert.Equal("Server exited (code 7)", monitor.History.Events.Last().Message); + } + + [Fact] + public async Task CoreProgressWorksWithoutAUiSynchronizationContext() { + await Task.Run(() => { + Assert.Null(SynchronizationContext.Current); + var progress = new AccumulatedMemoryProgress(); + var child = new MemoryProgress("install", 25); + progress.AlsoTrack(child); + Assert.Single(progress.Progresses); + Assert.Equal(25, progress.ProgressPercentage); + child.Report(75); + Assert.Equal(75, progress.ProgressPercentage); + }); + } + private static ServerSample Sample(int? players = null, string? map = null, bool available = false) => + new(DateTimeOffset.Now, TimeSpan.FromHours(26), 12.5, 1024, players, players.HasValue ? 64 : null, map, available); + + [Fact] + public void HistoryAndTimelineStayBounded() { + var history = new ServerHistory(); + var start = DateTimeOffset.Now; + for (var i = 0; i < ServerHistory.MaxSamples + 10; i++) + history.Add(Sample(i, "Map", true) with { Time = start.AddSeconds(i * 10) }); + Assert.Equal(ServerHistory.MaxSamples, history.Samples.Count); + Assert.Equal(ServerHistory.MaxEvents, history.Events.Count); + Assert.Equal(ServerHistory.MaxSamples + 9, history.Samples.Last().Players); + } + + [Fact] + public void TimelineRecordsChangesButNotRepeatedIdenticalStatus() { + var history = new ServerHistory(); + history.Add(Sample()); + Assert.Single(history.Events); + history.Add(Sample()); + Assert.Single(history.Events); + history.Add(Sample(0, "Map1", true)); + Assert.Equal(4, history.Events.Count); + history.Add(Sample(0, "Map1", true)); + Assert.Equal(4, history.Events.Count); + history.Add(Sample(2, "Map2", true)); + Assert.Equal(6, history.Events.Count); + history.Add(Sample()); + Assert.Equal(7, history.Events.Count); + Assert.Null(history.Samples.Last().Players); + } + + [Fact] + public void UnknownAndZeroPlayersAreDifferentInDashboard() { + var history = new ServerHistory(); + Assert.Contains(ServerDashboard.FormatLines(42, Sample(), history, 80), l => l.Contains("Players unknown")); + Assert.Contains(ServerDashboard.FormatLines(42, Sample(0, "Map", true), history, 80), l => l.Contains("Players 0/64")); + Assert.Contains(ServerDashboard.FormatLines(42, Sample(), history, 80), l => l.Contains("1d 02:00:00")); + } + + [Fact] + public void SparklinesRespectWidthUnknownSamplesAndScale() { + Assert.Equal(".?@", ServerDashboard.Sparkline([0, null, 100], 10, 100)); + Assert.Equal(".@", ServerDashboard.Sparkline([0, null, 100], 2, 100)); + Assert.Equal("..", ServerDashboard.Sparkline([0, 0], 10)); + Assert.NotEmpty(ServerDashboard.FormatLines(1, Sample(), new ServerHistory(), 1)); + Assert.DoesNotContain('\x1b', ServerDashboard.SafeText("map\x1b[2J\nInjected")); + } + + private sealed class FailingQuery : IA2S { + public Task InfoAsync() => Task.FromException(new TimeoutException()); + } + + private sealed class PendingQuery : IA2S { + public Task InfoAsync() => new TaskCompletionSource().Task; + } + + private sealed class RespondingQuery : IA2S { + public Task InfoAsync() => Task.FromResult(new A2SInfo(17, "Server", "FFA_Courtyard", "FFA", + "Chivalry 2", 0, 5, 64, 0, ServerType.Dedicated, + UnchainedLauncher.Core.Services.Server.A2S.Environment.Windows, true, false)); + } + + [Fact] + public async Task MonitorCollectsGameTypeAndResourceDetailsFromRunningProcess() { + using var process = Process.GetCurrentProcess(); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + using var dashboard = new ServerDashboard(false); + var monitor = new ServerMonitor(process, new RespondingQuery()); + await monitor.RunAsync(dashboard, stop); + var sample = Assert.Single(monitor.History.Samples); + Assert.True(sample.QueryAvailable); + Assert.Equal("FFA", sample.GameType); + Assert.Equal("FFA_Courtyard", sample.Map); + Assert.Equal(5, sample.Players); + Assert.Equal(64, sample.MaxPlayers); + Assert.NotNull(sample.HandleCount); + Assert.True(sample.PeakMemoryMiB >= sample.MemoryMiB); + Assert.Equal("Up", monitor.History.State); + } + + [Fact] + public async Task QueryFailureStillProducesResourceSamplesWithUnknownPlayers() { + using var process = Process.GetCurrentProcess(); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + using var dashboard = new ServerDashboard(false); + var monitor = new ServerMonitor(process, new FailingQuery()); + await monitor.RunAsync(dashboard, stop); + var sample = Assert.Single(monitor.History.Samples); + Assert.False(sample.QueryAvailable); + Assert.Null(sample.Players); + Assert.NotNull(sample.MemoryMiB); + Assert.NotNull(sample.HandleCount); + Assert.Equal(sample.MemoryMiB, sample.PeakMemoryMiB); + Assert.True(monitor.History.Events.Last().Message == "Stop requested"); + } + + [Fact] + public void DashboardIncludesAllRunningServerGuiDataWithoutSecrets() { + var options = Assert.IsType(CommandLineOptions.Parse([ + "server", "--chivalry2-path", ".", "--platform", "Steam", + "--name", "Parity server", "--local-ip", "192.0.2.1", + "--game-port", "7000", "--query-port", "7001", "--ping-port", "7002", "--rcon-port", "7003", + "--server-mod", "/Game/Mods/Example.Actor_C", "--password", "must-not-display" + ])); + var sample = Sample(5, "FFA_Courtyard", true) with { + GameType = "FFA", + HandleCount = 1234, + PeakMemoryMiB = 2048 + }; + var history = new ServerHistory(); + history.Add(sample); + var text = string.Join('\n', ServerDashboard.FormatLines(42, sample, history, 80, options.ToServerLaunchOptions())); + foreach (var expected in new[] { "Parity server", "PID 42", "1d 02:00:00", "Players 5/64", + "FFA_Courtyard", "Game Type FFA", "Game Port 7000", "Query Port 7001", "Beacon Port 7002", + "RCON Port 7003", "Local IP 192.0.2.1", "/Game/Mods/Example.Actor_C", "State Up", + "Restarts 0", "Private memory 1024.0 MiB", "Peak memory 2048.0 MiB", "Handle Count 1234", + "Uptime S=start U=up U", "Memory", "Players", "12 hours", "10s samples" }) + Assert.Contains(expected, text); + Assert.DoesNotContain("must-not-display", text); + } + + [Fact] + public void HistorySamplesEveryTenSecondsAndKeepsUpStateThroughQueryOutages() { + var start = DateTimeOffset.Now; + var history = new ServerHistory(); + history.Add(Sample() with { Time = start }); + Assert.Equal("Starting", history.State); + history.Add(Sample(0, "Map", true) with { Time = start.AddSeconds(1) }); + Assert.Single(history.Samples); + Assert.Equal("Up", history.State); + history.Add(Sample() with { Time = start.AddSeconds(10) }); + Assert.Equal(2, history.Samples.Count); + Assert.True(history.Samples.First().Starting); + Assert.False(history.Samples.Last().Starting); + Assert.Null(history.Samples.Last().Players); + Assert.Equal("Up", history.State); + } + + [Fact] + public void NarrowTerminalWrappingPreservesAllData() { + const string line = "Mods /Game/Mods/LongActorName.Actor_C | RCON Port 7003"; + var wrapped = ServerDashboard.WrapLine(line, 12).ToArray(); + Assert.All(wrapped, part => Assert.InRange(part.Length, 1, 12)); + Assert.Equal(line, string.Concat(wrapped)); + Assert.Equal([""], ServerDashboard.WrapLine("", 1)); + } + + [Fact] + public async Task CancellationInterruptsPendingQuery() { + using var process = Process.GetCurrentProcess(); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + using var dashboard = new ServerDashboard(false); + var monitor = new ServerMonitor(process, new PendingQuery()); + await monitor.RunAsync(dashboard, stop).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Empty(monitor.History.Samples); + Assert.Equal("Stop requested", monitor.History.Events.Last().Message); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs new file mode 100644 index 00000000..244c84fb --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -0,0 +1,198 @@ +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class ServerOptionsTests { + private static string[] Args(params string[] extra) => + new[] { "server", "--chivalry2-path", ".", "--platform", "Steam" }.Concat(extra).ToArray(); + + [Fact] + public void DefaultsProduceHeadlessServerLaunch() { + var options = Assert.IsType(CliLaunchOptions.Parse(Args())); + var server = options.ToServerLaunchOptions(); + Assert.True(server.Headless); + Assert.False(server.RegisterWithBackend); + Assert.True(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.NotNull(options.Settings.LocalIp); + Assert.Equal(options.Settings.LocalIp, server.LocalIp.IfNone("")); + Assert.Equal(7777, server.GamePort); + Assert.Equal(7071, server.QueryPort); + Assert.Equal("FFA_Courtyard", server.Map); + Assert.StartsWith("TBL -server ", options.ToLaunchOptions().LaunchArgs); + Assert.Equal("UnchainedCLI", options.ToLaunchOptions().SavedDirSuffix.IfNone("")); + Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "-nullrhi"); + Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "--desync-patch"); + } + + [Theory] + [InlineData("Steam", InstallationType.Steam)] + [InlineData("steam", InstallationType.Steam)] + [InlineData("EGS", InstallationType.EpicGamesStore)] + [InlineData("EpicGamesStore", InstallationType.EpicGamesStore)] + public void AcceptsPlatforms(string platform, InstallationType expected) { + Assert.Equal(expected, CliLaunchOptions.Parse(["server", "--chivalry2-path=.", $"--platform={platform}"]).Shared.Platform); + } + + [Fact] + public void RepeatedCoordinatesRegistriesAndActorsAreAccepted() { + var options = CliLaunchOptions.Parse(Args("--mod", "org/one:v1.2.3", "--mod=org/two:release-tag", + "--dll", "org/plugin:v2", "--add-registry", "github:owner/repo", + "--add-registry", "local:my registry", "--add-registry", "config:registries.json", + "--server-mod", "/Game/Mods/Test.Test_C", "--server-mod", "/Game/Mods/Other.Other_C")); + Assert.Equal(2, options.Shared.Mods.Count); + Assert.Single(options.Shared.Dlls); + Assert.Equal(3, options.Shared.Registries.Count); + Assert.Equal(2, Assert.IsType(options).ToServerLaunchOptions().ServerMods.Count()); + Assert.Equal("v1.2.3", options.Shared.Mods[0].Version); + Assert.True(Path.IsPathFullyQualified(options.Shared.Registries[1].Location)); + } + + [Fact] + public void GameArgumentsAreEscapedAndNotParsedAsLauncherOptions() { + var options = CliLaunchOptions.Parse(Args("--", "-AUTH_PASSWORD=secret with spaces", "--mod", "game value")); + Assert.Empty(options.Shared.Mods); + Assert.Equal(3, options.GameArguments.Count); + Assert.Contains("\"-AUTH_PASSWORD=secret with spaces\"", options.ToLaunchOptions().LaunchArgs); + Assert.Contains("\"game value\"", options.ToLaunchOptions().LaunchArgs); + } + + [Fact] + public void ServerFieldsAreMappedToCoreOptions() { + var options = CliLaunchOptions.Parse(Args("--name", "Test Server", "--description", "Test MOTD", "--starting-map", "TO_Test", + "--password", "secret", "--local-ip", "127.0.0.2", "--game-port", "10001", "--ping-port", "10002", + "--query-port", "10003", "--rcon-port", "10004", "--register", "--no-tui", "--no-plugin-update", + "--allow-unstable-plugin", "--server-browser-backend", "https://example.test", "--saved-dir-suffix", "Test_1")); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.Equal("Test Server", server.Name); + Assert.Equal("Test MOTD", server.Description); + Assert.Equal("TO_Test", server.Map); + Assert.Equal("secret", server.Password.IfNone("")); + Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); + Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); + Assert.True(server.RegisterWithBackend); + Assert.True(Assert.IsType(options).NoTui); + Assert.False(options.ToLaunchOptions().CheckForDependencyUpdates); + Assert.True(options.ToLaunchOptions().AllowUnstablePluginReleases); + } + + [Theory] + [InlineData("--mod", "missing-version")] + [InlineData("--mod", "org/mod:")] + [InlineData("--dll", "plugin:v1")] + [InlineData("--platform", "42")] + [InlineData("--game-port", "0")] + [InlineData("--game-port", "65536")] + [InlineData("--query-port", "7777")] + [InlineData("--saved-dir-suffix", "../profile")] + [InlineData("--local-ip", "not-an-ip")] + [InlineData("--server-browser-backend", "file:///server")] + [InlineData("--add-registry", "github:bad")] + [InlineData("--add-registry", "http:unsupported")] + [InlineData("--name", " ")] + [InlineData("--starting-map", " ")] + [InlineData("--headless", "yes")] + [InlineData("--censor-mode", "invalid")] + [InlineData("--discord-mention-admins", "maybe")] + public void RejectsInvalidValues(string key, string value) { + Assert.Throws(() => CliLaunchOptions.Parse(Args(key, value))); + } + + [Fact] + public void RejectsMissingRequiredOptionsAndUnknownFlags() { + Assert.Throws(() => CliLaunchOptions.Parse(["server"])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", "."])); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--typo"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--starting-map"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--map", "TO_Test"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--no-tui=true"))); + Assert.Throws(() => CliLaunchOptions.Parse(["client"])); + } + + [Fact] + public void RejectsConflictingModVersionsAndDuplicateScalarOptions() { + Assert.Throws(() => CliLaunchOptions.Parse(Args("--mod", "org/mod:v1", "--mod", "org/mod:v2"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--starting-map", "one", "--starting-map", "two"))); + } + + [Fact] + public void HelpDoesNotRequireInstallation() { + Assert.True(CliLaunchOptions.Parse([]).Help); + Assert.True(CliLaunchOptions.Parse(["--help"]).Help); + Assert.True(CliLaunchOptions.Parse(["server", "--help"]).Help); + var help = CliLaunchOptions.Parse(["server", "--help"]).HelpText; + Assert.Contains("--starting-map", help); + Assert.DoesNotContain("--map", help); + } + + [Fact] + public void GitHubRegistryCreatesCoreImplementation() { + var registry = Assert.IsType(RegistrySpec.Parse("github:owner/repo").Create()); + Assert.Equal("owner", registry.Organization); + Assert.Equal("repo", registry.RepoName); + } + + [Fact] + public void ExtendedServerArgumentsAreMappedToCore() { + var options = CliLaunchOptions.Parse(Args("--headless=false", "--no-desync-patch", + "--censor-mode=standard", "--discord-bot-token=token", + "--discord-general-channel-id=123", "--discord-mention-admins=false")); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.False(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.Equal(Core.Services.Processes.Chivalry.CensorArg.Standard, server.CensorMode); + Assert.Null(server.FFATimeLimit); + Assert.Null(server.PlayerBotCount); + Assert.True(server.DiscordIntegration.IsSome); + Assert.Contains("--discord-mention-admins false", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("-nullrhi", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--desync-patch", server.ToCLIArgs().Select(arg => arg.Rendered)); + } + + [Theory] + [InlineData("--headless=false")] + [InlineData("--headless", "false")] + public void RenderingRequiresExplicitlyDisablingDesyncPatch(params string[] arguments) { + var error = Assert.Throws(() => CliLaunchOptions.Parse(Args(arguments))); + Assert.Contains("headless", error.Message); + Assert.Contains("--no-desync-patch", error.Message); + Assert.Contains("server.desync-patch: false", error.Message); + var options = CliLaunchOptions.Parse(Args(arguments.Concat(["--no-desync-patch"]).ToArray())); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.False(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.DoesNotContain("-nullrhi", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--desync-patch", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsSome); + } + + [Fact] + public void DefaultEnabledServerFlagsCanBeDisabledOnCommandLine() { + var options = CliLaunchOptions.Parse(Args("--no-desync-patch", "--no-backend-banlist")); + Assert.False(Assert.IsType(options).ToServerLaunchOptions().DesyncPatch); + Assert.False(Assert.IsType(options).ToServerLaunchOptions().UseBackendBanlist); + } + + [Fact] + public void ClientLaunchDoesNotApplyServerDesyncCompatibilityPolicy() { + var options = Assert.IsType(CliLaunchOptions.Parse([ + "client", "--chivalry2-path", ".", "--platform", "Steam" + ])); + var launch = options.ToLaunchOptions(); + Assert.True(launch.ServerLaunchOptions.IsNone); + Assert.Equal("TBL", launch.LaunchArgs); + } + + [Theory] + [InlineData("--no-desync-patch=false")] + [InlineData("--no-backend-banlist=true")] + [InlineData("--desync-patch")] + [InlineData("--use-backend-banlist")] + public void DisableOptionsAreValueLessFlags(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse(Args(flag))); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs new file mode 100644 index 00000000..38eaa64e --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs @@ -0,0 +1,176 @@ +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class ServerProfileTests : IDisposable { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), "server-profile-tests-" + Guid.NewGuid().ToString("N")); + + public void Dispose() { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + + private CliLaunchOptions Request(string command) => CliLaunchOptions.Parse([ + command, "--chivalry2-path", _directory, "--platform", "Steam", "--saved-dir-suffix", "ServerProfileTest", + .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [] + ]); + + [Fact] + public void ServerCreatesStartupSettingsInTheSelectedProfile() { + ServerLauncher.InitializeServerProfile(Request("server"), suffix => { + Assert.Equal("ServerProfileTest", suffix); + return _directory; + }); + + var ini = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("[/Script/TBL.TBLTitleScreen]", ini); + Assert.Contains("bSavedHasAgreedToTOS=True", ini); + Assert.Contains("[/Script/TBL.TBLGameInstance]", ini); + Assert.Contains("FirstLoadCompleted=True", ini); + Assert.Single(Directory.GetFiles(_directory)); + } + + [Fact] + public void ServerInitializesTheGameObservedHyphenStrippedProfile() { + var request = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam", + "--saved-dir-suffix", "Host-court-yard", "--local-ip", "127.0.0.1"]); + ServerLauncher.InitializeServerProfile(request, suffix => { + Assert.Equal("Hostcourtyard", suffix); + return _directory; + }); + var ini = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("bSavedHasAgreedToTOS=True", ini); + Assert.Contains("FirstLoadCompleted=True", ini); + } + + [Fact] + public void HyphenOnlyServerProfileIsRejected() { + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, + "--platform", "Steam", "--saved-dir-suffix", "---", "--local-ip", "127.0.0.1"])); + } + + [Fact] + public void ServerUpdatesStartupSettingsWithoutChangingOtherSettingsAndIsIdempotent() { + Directory.CreateDirectory(_directory); + var path = Path.Combine(_directory, "Game.ini"); + File.WriteAllText(path, """ + ; custom server settings + [/Script/TBL.TBLGameMode] + ServerName=Keep me + MapList=FFA_Courtyard + MapList=FFA_Wardenglade + [/Script/TBL.TBLTitleScreen] + bSavedHasAgreedToTOS=False + CustomTitleSetting=42 + [/Script/TBL.TBLGameInstance] + FirstLoadCompleted=False + CustomInstanceSetting=Keep this too + """); + File.WriteAllText(Path.Combine(_directory, "Engine.ini"), "engine untouched"); + File.WriteAllText(Path.Combine(_directory, "GameUserSettings.ini"), "player untouched"); + + ServerLauncher.InitializeServerProfile(Request("server"), _ => _directory); + var ini = File.ReadAllText(path); + Assert.Contains("bSavedHasAgreedToTOS=True", ini); + Assert.Contains("FirstLoadCompleted=True", ini); + Assert.DoesNotContain("=False", ini); + foreach (var setting in new[] { "; custom server settings", "ServerName=Keep me", "MapList=FFA_Courtyard", + "MapList=FFA_Wardenglade", "CustomTitleSetting=42", "CustomInstanceSetting=Keep this too" }) + Assert.Contains(setting, ini); + Assert.Equal("engine untouched", File.ReadAllText(Path.Combine(_directory, "Engine.ini"))); + Assert.Equal("player untouched", File.ReadAllText(Path.Combine(_directory, "GameUserSettings.ini"))); + + ServerLauncher.InitializeServerProfile(Request("server"), _ => _directory); + Assert.Equal(ini, File.ReadAllText(path)); + } + + [Fact] + public void ClientDoesNotResolveOrCreateAProfile() { + ServerLauncher.InitializeServerProfile(Request("client"), _ => throw new InvalidOperationException("Client must not initialize a profile.")); + Assert.False(Directory.Exists(_directory)); + } + + [Fact] + public void ServerPropagatesProfileWriteFailure() { + Directory.CreateDirectory(_directory); + var blocked = Path.Combine(_directory, "blocked"); + File.WriteAllText(blocked, "not a directory"); + Assert.ThrowsAny(() => ServerLauncher.InitializeServerProfile(Request("server"), _ => blocked)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ComposedPatchesPreserveDefaultsNeighborsAndArraySemantics(bool rotation) { + Directory.CreateDirectory(_directory); + var config = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(config, """ + chiv2-path: . + platform: Steam + server: + local-ip: 127.0.0.1 + ini: + game: + /script/tbl.tbltitlescreen: + bSavedHasAgreedToTOS: false + /Script/TBL.TBLGameInstance: + Custom: {type: String, value: selected} + /Script/TBL.TBLGameMode: + ServerName: selected name + Empty: {type: 'String[]', value: []} + Repeats: {type: 'String[]', value: [A, A, B]} + RawOnly: {type: raw, value: secret} + Engine: + Custom: + Selected: {type: Int, value: 7} + """ + (rotation ? "\n map-rotation:\n - {map-name: FFA_New}\n - {map-name: FFA_New}\n" : "")); + File.WriteAllText(Path.Combine(_directory, "Game.ini"), """ + [/Script/TBL.TBLTitleScreen] + Neighbor=title + [/Script/TBL.TBLGameInstance] + Neighbor=instance + [/Script/TBL.TBLGameMode] + Neighbor=mode + .Empty=old + +Repeats=old + MapList=FFA_Old + MapListIndex=3 + """); + File.WriteAllText(Path.Combine(_directory, "Engine.ini"), "[Custom]\nNeighbor=engine\nSelected=1\n"); + File.WriteAllText(Path.Combine(_directory, "GameUserSettings.ini"), "untouched"); + var request = CliLaunchOptions.Parse(["server", "--config-path", config]); + + ServerLauncher.InitializeServerProfile(request, _ => _directory); + + var game = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("bSavedHasAgreedToTOS=False", game); + Assert.DoesNotContain("bSavedHasAgreedToTOS=True", game); + Assert.Contains("FirstLoadCompleted=True", game); + Assert.Contains("Custom=selected", game); + foreach (var neighbor in new[] { "title", "instance", "mode" }) Assert.Contains("Neighbor=" + neighbor, game); + Assert.Contains("ServerName=selected name", game); + Assert.Contains("!Empty=ClearArray", game); + Assert.DoesNotContain(".Empty=", game); + Assert.Contains("!Repeats=ClearArray", game); + Assert.DoesNotContain("+Repeats=old", game); + Assert.Equal(new[] { ".Repeats=A", ".Repeats=A", ".Repeats=B" }, + game.Split('\n').Select(line => line.TrimEnd('\r')).Where(line => line.StartsWith(".Repeats="))); + Assert.DoesNotContain("RawOnly", game); + if (rotation) { + Assert.Contains("!MapList=ClearArray", game); + Assert.Equal(2, game.Split('\n').Count(line => line.TrimEnd('\r') == ".MapList=FFA_New")); + Assert.Contains("MapListIndex=0", game); + Assert.DoesNotContain("FFA_Old", game); + } + else { + Assert.Contains("MapList=FFA_Old", game); + Assert.Contains("MapListIndex=3", game); + } + var engine = File.ReadAllText(Path.Combine(_directory, "Engine.ini")); + Assert.Contains("Neighbor=engine", engine); + Assert.Contains("Selected=7", engine); + Assert.Equal("untouched", File.ReadAllText(Path.Combine(_directory, "GameUserSettings.ini"))); + ServerLauncher.InitializeServerProfile(request, _ => _directory); + Assert.Equal(game, File.ReadAllText(Path.Combine(_directory, "Game.ini"))); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerTabsTests.cs b/UnchainedLauncher.CLI.Tests/ServerTabsTests.cs new file mode 100644 index 00000000..76fa6113 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerTabsTests.cs @@ -0,0 +1,59 @@ +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class ServerTabsTests { + [Fact] + public void ArrowNavigationWrapsAndClickSelectsVisibleTabs() { + var tabs = new ServerTabs(["one", "two", "three"]); + Assert.Equal(">one< [two] [three] ", tabs.Layout(80)); + tabs.Move(-1); + Assert.Equal(2, tabs.Selected); + tabs.Move(1); + Assert.Equal(0, tabs.Selected); + Assert.True(tabs.Click(8, 1)); + Assert.Equal(1, tabs.Selected); + Assert.False(tabs.Click(8, 2)); + Assert.False(tabs.Click(80, 1)); + } + + [Fact] + public void NarrowTabBarKeepsSelectedTabVisibleAndRebuildsClickTargets() { + var tabs = new ServerTabs(["one", "two", "three"]); + Assert.Equal(">one<", tabs.Layout(5)); + tabs.Move(2); + Assert.Equal(">thre", tabs.Layout(5)); + Assert.True(tabs.Click(1, 1)); + Assert.Equal(2, tabs.Selected); + tabs.Move(1); + Assert.Equal(">one<", tabs.Layout(5)); + } + + [Theory] + [InlineData("\u001b[<0;8;1M", true)] + [InlineData("\u001b[<0;8;1m", false)] + [InlineData("\u001b[<2;8;1M", false)] + [InlineData("\u001b[<32;8;1M", false)] + [InlineData("\u001b[<0;0;1M", false)] + [InlineData("\u001b[<0;99999999999999999;1M", false)] + public void MouseParserAcceptsOnlyPrimaryPressesWithValidCoordinates(string report, bool expected) { + var parser = new TerminalMouseParser(); + (int Column, int Row)? result = null; + foreach (var value in report) { + Assert.True(parser.Feed(value, out var click)); + result = click; + } + Assert.Equal(expected, result.HasValue); + if (expected) Assert.Equal((8, 1), result!.Value); + } + + [Fact] + public void IncompleteOrMalformedMouseInputDoesNotSwallowQuit() { + var parser = new TerminalMouseParser(); + parser.Feed('\x1b', out _); + Assert.False(parser.Feed('q', out _)); + Assert.False(parser.Feed('q', out _)); + foreach (var value in "\x1b[<" + new string('1', 70)) parser.Feed(value, out _); + Assert.False(parser.Feed('q', out _)); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/SuspendedLaunchTests.cs b/UnchainedLauncher.CLI.Tests/SuspendedLaunchTests.cs new file mode 100644 index 00000000..b1d25e9c --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/SuspendedLaunchTests.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using System.Text.Json; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Utilities; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class SuspendedLaunchTests : IDisposable { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "suspended-launch-" + Guid.NewGuid().ToString("N")); + private static string Probe => Path.Combine(AppContext.BaseDirectory, "argument-probe", "ArgumentProbe.exe"); + + public SuspendedLaunchTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Theory] + [InlineData("success")] + [InlineData("false")] + [InlineData("throw")] + public async Task NativeMainThreadRunsOnlyAfterSuccessfulInitialization(string outcome) { + if (!OperatingSystem.IsWindows()) return; + var output = Path.Combine(_directory, "arguments.json"); + Process? observed = null; + try { + IProcessLauncher launcher = new ProcessLauncher(Probe); + var calls = 0; + var result = launcher.Launch(_directory, ArgumentEscaper.Escape(output) + " \"space value\"", process => { + calls++; + observed = Process.GetProcessById(process.Id); + Thread.Sleep(250); + Assert.False(File.Exists(output)); + if (outcome == "throw") throw new InvalidOperationException("test injection failure"); + return outcome == "success"; + }); + Assert.Equal(1, calls); + Assert.NotNull(observed); + if (outcome == "success") { + using var process = result.Match(Left: error => throw error, Right: process => process); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + await process.WaitForExitAsync(timeout.Token); + Assert.Equal(0, process.ExitCode); + Assert.Equal(new[] { "space value" }, JsonSerializer.Deserialize(await File.ReadAllTextAsync(output))); + } + else { + Assert.True(result.IsLeft); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await observed!.WaitForExitAsync(timeout.Token); + Assert.False(File.Exists(output)); + } + } + finally { + if (observed is not null) { + if (!observed.HasExited) observed.Kill(entireProcessTree: true); + observed.Dispose(); + } + } + } + + [Fact] + public void FailedCreationDoesNotInitialize() { + IProcessLauncher launcher = new ProcessLauncher(Path.Combine(_directory, "missing.exe")); + var called = false; + var result = launcher.Launch(_directory, "", _ => { called = true; return true; }); + Assert.True(result.IsLeft); + Assert.False(called); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj b/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj new file mode 100644 index 00000000..76a88a53 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs new file mode 100644 index 00000000..bb00cc99 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -0,0 +1,589 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +[Collection("Installation directory")] +public class YamlConfigurationTests : IDisposable { + private readonly string _directory = Path.Combine( + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "yaml-cli-tests-" + Guid.NewGuid().ToString("N")); + + public YamlConfigurationTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private string Write(string body, bool includeRequired = true) { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, (includeRequired ? "chiv2-path: './game installation'\nplatform: Steam\n" : "") + body); + return path; + } + + private CliLaunchOptions Load(string body, bool client = false) => + CliLaunchOptions.Parse([client ? "client" : "server", "--config-path", Write(body)]); + + [Theory] + [InlineData("a", "a")] + [InlineData("Z09_", "Z09_")] + [InlineData("_", "_")] + [InlineData("-Host-one-", "Hostone")] + public void CliAndYamlSuffixesUseTheSameProfileNormalization(string suffix, string normalized) { + foreach (var command in new[] { "client", "server" }) { + var cli = CliLaunchOptions.Parse([command, "--chivalry2-path", ".", "--platform", "Steam", "--saved-dir-suffix=" + suffix]); + var yaml = Load(command == "client" ? $"saved-dir-suffix: '{suffix}'" : $"server: {{saved-dir-suffix: '{suffix}'}}", command == "client"); + foreach (var options in new[] { cli, yaml }) { + Assert.Equal(suffix, options.Shared.SavedDirSuffix); + Assert.Equal(normalized, options.Shared.Assets.Suffix); + } + } + } + + [Theory] + [InlineData("")] + [InlineData("---")] + [InlineData("é")] + [InlineData("A")] + [InlineData("../outside")] + [InlineData("with space")] + [InlineData("line\nbreak")] + [InlineData("name\\other")] + public void CliAndYamlRejectInvalidSuffixesAtTheProfileBoundary(string suffix) { + var expected = Assert.Throws(() => new UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths(suffix)); + foreach (var command in new[] { "client", "server" }) { + var cli = Assert.Throws(() => CliLaunchOptions.Parse([ + command, "--chivalry2-path", ".", "--platform", "Steam", "--saved-dir-suffix=" + suffix, "--mod=invalid"])); + var quoted = System.Text.Json.JsonSerializer.Serialize(suffix); + var yaml = Assert.Throws(() => Load(command == "client" + ? $"saved-dir-suffix: {quoted}\nmod-setup: {{mods: [invalid]}}" + : $"server: {{saved-dir-suffix: {quoted}, mod-setup: {{mods: [invalid]}}}}", command == "client")); + if (suffix.Length == 0) Assert.Contains("Required argument missing for option: '--saved-dir-suffix'", cli.Message); + else Assert.Equal(expected.Message, cli.Message); + if (suffix.Length == 0 && command == "client") Assert.Contains("The SavedDirSuffix field is required.", yaml.Message); + else Assert.Equal(expected.Message, yaml.Message); + } + } + + [Theory] + [InlineData(false, "::1")] + [InlineData(true, "::1")] + [InlineData(false, "::")] + [InlineData(true, "::")] + [InlineData(false, "::ffff:127.0.0.1")] + [InlineData(true, "::ffff:127.0.0.1")] + public void ServerRejectsIpv6DuringParsing(bool yaml, string address) { + var error = Assert.Throws(() => yaml + ? Load($"server: {{local-ip: '{address}'}}") + : CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", "--local-ip", address])); + Assert.Contains("local-ip must be an IPv4 address", error.Message); + } + + [Theory] + [InlineData(false, "127.0.0.1")] + [InlineData(true, "127.0.0.1")] + [InlineData(false, "192.168.50.10")] + [InlineData(true, "192.168.50.10")] + [InlineData(false, "0.0.0.0")] + [InlineData(true, "0.0.0.0")] + [InlineData(false, null)] + [InlineData(true, null)] + public void ServerAcceptsIpv4OverridesAndAutoDetection(bool yaml, string? address) { + var options = Assert.IsType(yaml + ? Load(address is null ? "server: {}" : $"server: {{local-ip: '{address}'}}") + : CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", + .. address is null ? Array.Empty() : ["--local-ip", address]])); + Assert.Equal(AddressFamily.InterNetwork, IPAddress.Parse(options.Settings.LocalIp!).AddressFamily); + if (address is not null) Assert.Equal(address, options.Settings.LocalIp); + Assert.Equal(options.Settings.LocalIp, options.ToServerLaunchOptions().LocalIp.IfNone("")); + } + + [Fact] + public void ConfigurationIsAuthoritativeWithEitherConfigPathSyntax() { + var path = Write("server:\n name: From YAML\n"); + foreach (var args in new[] { + new[] { "server", "--typo", "--name", "Ignored", "--config-path", path, "--", "-ignored-game-arg" }, + new[] { "server", "--config-path=" + path, "--platform=Invalid", "--help" } + }) { + var options = CliLaunchOptions.Parse(args); + Assert.IsType(options); + Assert.False(options.Help); + Assert.Equal("From YAML", Assert.IsType(options).Settings.Name); + Assert.Empty(options.GameArguments); + } + } + + [Fact] + public void AuthoritativeYamlIgnoresResponseFileTokensAndAllOtherOptionErrors() { + var path = Write("server: {name: From YAML}\n"); + var response = Path.Combine(_directory, "ignored.rsp"); + File.WriteAllText(response, "--config-path=must-not-be-loaded --name=must-not-be-bound"); + foreach (var command in new[] { "server", "client" }) { + foreach (var token in new[] { "@" + response, "@" + Path.Combine(_directory, "missing.rsp") }) { + string[] ignored = ["--name", token, "--name=duplicate", "--platform=Invalid", "--game-port=invalid", "--headless=invalid", + "--no-plugin-update", "--no-plugin-update", "--unknown", "unexpected", "--mod=invalid", token]; + foreach (var args in new string[][] { + [command, "--config-path", path, .. ignored, "--", "@game-response"], + [command, .. ignored, "--config-path=" + path], + [command, .. ignored, "--config-path=" + path, "--help"] + }) { + var options = CliLaunchOptions.Parse(args); + Assert.False(options.Help); + Assert.True(options.Shared.UpdatePlugin); + Assert.Empty(options.GameArguments); + if (command == "server") Assert.Equal("From YAML", Assert.IsType(options).Settings.Name); + else Assert.IsType(options); + } + } + } + } + + [Fact] + public void OrdinaryOptionValuesDoNotExpandExistingOrMissingResponseFiles() { + var response = Path.Combine(_directory, "ordinary.rsp"); + File.WriteAllText(response, "--name expanded --platform Invalid"); + foreach (var token in new[] { "@" + response, "@" + Path.Combine(_directory, "missing.rsp") }) { + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", "--name", token]); + Assert.Equal(token, Assert.IsType(options).Settings.Name); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", token])); + } + } + + [Theory] + [InlineData("server", false)] + [InlineData("server", true)] + [InlineData("client", false)] + [InlineData("client", true)] + public void OneLibraryParseControlsBothOrdinaryAndYamlLaunches(string command, bool yaml) { + string[] args = yaml + ? [command, "--config-path", Write(""), "--platform=Invalid", "--unknown"] + : [command, "--chivalry2-path", ".", "--platform", "Steam"]; + var calls = 0; + var options = CommandLineOptions.Parse([.. args, "--", "@game-file", "--config-path", "not-config"], (root, tokens, configuration) => { + calls++; + Assert.Null(configuration.ResponseFileTokenReplacer); + Assert.Equal(args, tokens); + Assert.Equal(new[] { "server", "client", "mod-query", "dll-query" }, root.Subcommands.Select(subcommand => subcommand.Name)); + Assert.Single(root.Options.Where(option => option.Name == "--config-path")); + foreach (var subcommand in root.Subcommands.Where(command => command.Name is "client" or "server")) { + Assert.Single(subcommand.Options.Where(option => option.Name == "--platform")); + Assert.Single(subcommand.Options.Where(option => option.Name == "--chivalry2-path")); + } + return root.Parse(tokens, configuration); + }); + Assert.Equal(1, calls); + Assert.Equal(command == "server", options is ServerOptions); + Assert.Equal(yaml ? [] : new[] { "@game-file", "--config-path", "not-config" }, Assert.IsAssignableFrom(options).GameArguments); + } + + [Fact] + public void AuthoritativeYamlStillRejectsMissingEmptyAndDuplicateSelectors() { + var path = Write(""); + foreach (var command in new[] { "server", "client" }) { + foreach (var selectors in new[] { + new[] { "--config-path" }, + new[] { "--config-path=" }, + new[] { "--config-path", " " }, + new[] { "--config-path", path, "--config-path", path }, + new[] { "--config-path=" + path, "--config-path=" + path }, + new[] { "--config-path", path, "--config-path" } + }) { + Assert.Throws(() => CliLaunchOptions.Parse([command, "--platform=Invalid", .. selectors])); + Assert.Throws(() => CliLaunchOptions.Parse([command, "--platform=Invalid", .. selectors, "--help"])); + } + } + Assert.Throws(() => CliLaunchOptions.Parse(["--config-path", path])); + Assert.Throws(() => CliLaunchOptions.Parse(["--config-path", path, "--help"])); + } + + [Fact] + public void ConfigPathAfterGameSeparatorIsForwardedNotLoaded() { + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam", "--", "--config-path", "game-file"]); + Assert.Equal(new[] { "--config-path", "game-file" }, options.GameArguments); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SubcommandSelectsModeRegardlessOfIncludedBlocks(bool client) { + var options = Load("client: {}\nserver: {}\n", client); + Assert.Equal(client, options is ClientOptions); + Assert.Equal(client, options.ToLaunchOptions().ServerLaunchOptions.IsNone); + if (client) Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + else Assert.StartsWith("TBL -server ", options.ToLaunchOptions().LaunchArgs); + Assert.Equal(!client, options.ToLaunchOptions().ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); + } + + [Fact] + public void ExplicitClientIgnoresServerFieldsEvenWhenTheyAreInvalid() { + var options = Load(""" + client: + game-args: ['-client-only'] + game-args: ['-AUTH_PASSWORD=auth value'] + server: + game-port: not-a-port + headless: not-a-boolean + typo: whatever + mod-actors: not-a-list + discord: + bot-token: secret + general-channel-id: [] + game-args: ['-server'] + """, client: true); + Assert.IsType(options); + var launch = options.ToLaunchOptions(); + Assert.True(launch.ServerLaunchOptions.IsNone); + Assert.DoesNotContain("-server", launch.LaunchArgs); + Assert.Contains("\"-AUTH_PASSWORD=auth value\"", launch.LaunchArgs); + Assert.Contains("-client-only", launch.LaunchArgs); + } + + [Fact] + public void ServerIgnoresClientBlockAndEachCommandValidatesOnlyItsOwnSchema() { + var server = Load("client: not-a-mapping\nserver: {name: Correct}"); + Assert.Equal("Correct", Assert.IsType(server).Settings.Name); + Assert.Throws(() => Load("client: not-a-mapping", client: true)); + Assert.Throws(() => Load("client: {typo: invalid}", client: true)); + Assert.IsType(Load("server: not-a-mapping\nclient: {}", client: true)); + } + + public static TheoryData InactiveYamlNodes { + get { + var data = new TheoryData(); + foreach (var client in new[] { true, false }) { + foreach (var node in new[] { + "not-a-mapping", "null", "[]", "{}", "*unused-alias", + "{game-args: [], game-args: []}", + "{nested: {password: first-secret, password: second-secret}}", + "[{nested: {field: one, field: two}}, [scalar, {field: [], field: {}}]]", + "{nested: [{field: one, field: two}], nested: ignored}", + "!ignored-tag {field: one, field: two}", + "|-\n literal scalar\n field: not-a-mapping", + "\n nested:\n field: one\n field: two", + "\n - nested:\n field: one\n field: two\n - [scalar, {}]" + }) data.Add(client, node); + } + return data; + } + } + + [Theory] + [MemberData(nameof(InactiveYamlNodes))] + public void InactiveYamlNodeIsSkippedAtProductionLaunchBoundary(bool client, string node) { + var active = client ? "client" : "server"; + var inactive = client ? "server" : "client"; + var options = Load($"{inactive}: {node}\n{active}: {{game-args: ['-active']}}\ngame-args: ['-shared']\n", client); + Assert.Equal(client, options is ClientOptions); + var launch = options.ToLaunchOptions(); + Assert.Equal(client, launch.ServerLaunchOptions.IsNone); + Assert.Contains("-active", launch.LaunchArgs); + Assert.Contains("-shared", launch.LaunchArgs); + Assert.DoesNotContain("secret", launch.LaunchArgs); + Assert.Equal(!client, launch.ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InactiveYamlAliasIsNotBoundToAnActiveObject(bool client) { + var active = client ? "client" : "server"; + var inactive = client ? "server" : "client"; + var options = Load($"{active}: &active {{game-args: ['-active']}}\n{inactive}: *active\n", client); + Assert.Equal(client, options is ClientOptions); + Assert.Contains("-active", options.ToLaunchOptions().LaunchArgs); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SkippingInactiveYamlRetainsRootSharedAndActiveDuplicateChecks(bool client) { + var active = client ? "client" : "server"; + var inactive = client ? "server" : "client"; + foreach (var body in new[] { + "platform: Steam\n", + "game-args: []\ngame-args: []\n", + "mod-setup: {mods: [], mods: []}\n", + $"{active}: {{game-args: [], game-args: []}}\n", + $"{active}: {{}}\n{active}: {{}}\n", + $"{inactive}: {{}}\n" + }) { + var error = Assert.Throws(() => Load($"{inactive}: {{nested: {{field: one, field: two}}}}\n" + body, client)); + Assert.Contains("Invalid YAML configuration or schema", error.Message); + } + if (!client) { + Assert.Throws(() => Load("client: {}\nserver: {discord: {bot-token: first-secret, bot-token: second-secret}}\n")); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SkippedInactiveYamlStillRequiresValidSyntaxWithoutExposingSecrets(bool client) { + var inactive = client ? "server" : "client"; + var error = Assert.Throws(() => Load($"{inactive}: {{password: TOP-SECRET, nested: [}}\n", client)); + Assert.Contains("Invalid YAML configuration or schema", error.Message); + Assert.DoesNotContain("TOP-SECRET", error.Message); + } + + [Fact] + public void ServerSafetyFlagsDefaultOnAndCanBeDisabledInYaml() { + var defaults = Assert.IsType(Load("server: {}")).ToServerLaunchOptions(); + Assert.True(defaults.Headless); + Assert.True(defaults.DesyncPatch); + Assert.True(defaults.UseBackendBanlist); + Assert.True(defaults.LocalIp.IsSome); + Assert.Contains("-nullrhi", defaults.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.Contains("--desync-patch", defaults.ToCLIArgs().Select(arg => arg.Rendered)); + var disabled = Assert.IsType(Load("server: {desync-patch: false, use-backend-banlist: false}")).ToServerLaunchOptions(); + Assert.False(disabled.DesyncPatch); + Assert.False(disabled.UseBackendBanlist); + Assert.DoesNotContain("--desync-patch", disabled.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--use-backend-banlist", disabled.ToCLIArgs().Select(arg => arg.Rendered)); + } + + [Theory] + [InlineData("server: {headless: false}")] + [InlineData("server: {headless: false, desync-patch: true}")] + public void RenderingRequiresExplicitlyDisablingDesyncPatchInYaml(string yaml) { + var error = Assert.Throws(() => Load(yaml)); + Assert.Contains("headless", error.Message); + Assert.Contains("--no-desync-patch", error.Message); + Assert.Contains("server.desync-patch: false", error.Message); + var client = Assert.IsType(Load(yaml, client: true)).ToLaunchOptions(); + Assert.True(client.ServerLaunchOptions.IsNone); + Assert.Equal("TBL", client.LaunchArgs); + } + + [Fact] + public void RenderingWithExplicitlyDisabledDesyncPatchInYamlIsAllowed() { + var options = Assert.IsType(Load("server: {headless: false, desync-patch: false}")); + var server = options.ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.False(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.DoesNotContain("-nullrhi", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--desync-patch", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsSome); + } + + [Theory] + [InlineData("ffa-time-limit")] + [InlineData("ffa-score-limit")] + [InlineData("tdm-time-limit")] + [InlineData("tdm-ticket-count")] + [InlineData("player-bot-count")] + [InlineData("warmup-time")] + public void IniFieldsAreNotExposedInYamlOrCommandLine(string field) { + Assert.Throws(() => Load($"server: {{{field}: 1}}")); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", $"--{field}=1"])); + Assert.IsType(Load($"server: {{{field}: 1}}", client: true)); + } + + [Fact] + public void PathsAreRelativeToYamlFileWithoutChangingCwdAndDefaultRegistryRemains() { + var cwd = Directory.GetCurrentDirectory(); + Directory.CreateDirectory(Path.Combine(_directory, "local registry")); + var options = Load(""" + update-plugin: false + allow-unstable-plugin: true + no-tui: true + server: + saved-dir-suffix: Test_1 + mod-setup: + registries: ['local:local registry', 'config:registries.json', 'github:owner/repo'] + mods: ['org/one:v1.2.3', 'org/two:v2.0.0'] + dlls: ['org/plugin:v1.0.0'] + """); + Assert.Equal(cwd, Directory.GetCurrentDirectory()); + Assert.Equal(Path.Combine(_directory, "game installation"), options.Shared.Chivalry2Path); + Assert.Equal(Path.Combine(_directory, "local registry"), options.Shared.Registries[0].Location); + Assert.Equal(Path.Combine(_directory, "registries.json"), options.Shared.Registries[1].Location); + Assert.Equal("owner/repo", options.Shared.Registries[2].Location); + Assert.Equal(2, options.Shared.Mods.Count); + Assert.Single(options.Shared.Dlls); + Assert.Equal("Test_1", options.Shared.SavedDirSuffix); + Assert.False(options.Shared.UpdatePlugin); + Assert.True(options.Shared.AllowUnstablePlugin); + Assert.True(Assert.IsType(options).NoTui); + var plain = Load("server: {mod-setup: { registries: [], mods: [], dlls: [] }}"); + var official = Assert.Single(ServerLauncher.CreateRegistry(plain).ModRegistries); + Assert.IsType(official); + Assert.Equal("Chiv2-Community", ((GithubModRegistry)official).Organization); + } + + [Fact] + public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { + var options = Load(""" + client: + typo: this ignored client field would be invalid in client mode + game-args: ['-client-only'] + server-browser-backend: https://example.test + game-args: ['-log'] + server: + headless: true + name: YAML Server + description: Example MOTD + password: private password + starting-map: TO_Test + local-ip: 127.0.0.2 + game-port: 10001 + ping-port: 10002 + query-port: 10003 + rcon-port: 10004 + register: true + mod-actors: ['/Game/A.A_C', '/Game/B.B_C', '/Game/A.A_C'] + desync-patch: true + use-backend-banlist: true + censor-mode: zealous + game-args: ['-custom=value with spaces'] + discord: + bot-token: '--secret-token' + admin-channel-id: '123456789012345678' + general-channel-id: '234567890123456789' + dashboard-channel-id: '345678901234567890' + event-log-channel-id: '456789012345678901' + admin-role-id: '567890123456789012' + mention-admins: false + """); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.True(server.Headless); + Assert.Equal("YAML Server", server.Name); + Assert.Equal("Example MOTD", server.Description); + Assert.Equal("private password", server.Password.IfNone("")); + Assert.Equal("TO_Test", server.Map); + Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); + Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); + Assert.True(server.RegisterWithBackend); + Assert.True(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.Equal(Core.Services.Processes.Chivalry.CensorArg.Zealous, server.CensorMode); + Assert.Null(server.FFATimeLimit); + Assert.Null(server.FFAScoreLimit); + Assert.Null(server.TDMTimeLimit); + Assert.Null(server.TDMTicketCount); + Assert.Null(server.PlayerBotCount); + Assert.Null(server.WarmupTime); + Assert.Equal(2, server.ServerMods.Count()); + var rendered = server.ToCLIArgs().Select(arg => arg.Rendered).ToArray(); + Assert.Contains("--server-mods /Game/A.A_C,/Game/B.B_C", rendered); + Assert.Contains("--desync-patch", rendered); + Assert.Contains("--use-backend-banlist", rendered); + Assert.Contains("--discord-bot-token --secret-token", rendered); + Assert.Contains("--discord-mention-admins false", rendered); + Assert.Contains("--discord-admin-channel-id 123456789012345678", rendered); + Assert.Contains("--discord-general-channel-id 234567890123456789", rendered); + Assert.Contains("--discord-dashboard-channel-id 345678901234567890", rendered); + Assert.Contains("--discord-event-log-channel-id 456789012345678901", rendered); + Assert.Contains("--discord-admin-role-id 567890123456789012", rendered); + Assert.Contains("-nullrhi", rendered); + Assert.Contains("-log", options.ToLaunchOptions().LaunchArgs); + Assert.Contains("\"-custom=value with spaces\"", options.ToLaunchOptions().LaunchArgs); + Assert.DoesNotContain("-client-only", options.ToLaunchOptions().LaunchArgs); + } + + [Theory] + [InlineData("typo: value")] + [InlineData("server: []")] + [InlineData("server: {mod-setup: {registries: not-a-list}}")] + [InlineData("server: {mod-setup: {mods: [null]}}")] + [InlineData("server: {mod-setup: {mods: ['org/mod:v1', 'org/mod:v2']}}")] + [InlineData("saved-dir-suffix: OldRootValue")] + [InlineData("mod-setup: {mods: []}")] + [InlineData("server: {query-port: 7777}")] + [InlineData("server: {game-port: 0}")] + [InlineData("server: {game-port: 1.5}")] + [InlineData("server: {headless: invalid}")] + [InlineData("server: {typo: true}")] + [InlineData("server: {map: TO_Test}")] + [InlineData("server: {starting-map: ''}")] + [InlineData("server: {censor-mode: invalid}")] + [InlineData("server: {warmup-time: -1}")] + [InlineData("server: {discord: {bot-token: secret}}")] + [InlineData("server: {discord: {general-channel-id: '123'}}")] + [InlineData("server: {name: one, name: two}")] + [InlineData("game-args: {invalid: mapping}")] + public void InvalidConfigurationFailsBeforeLaunching(string body) { + Assert.Throws(() => Load(body)); + } + + [Fact] + public void InvalidYamlMissingFilesAndDocumentsFailWithoutExposingSecrets() { + var malformed = Write("server:\n password: TOP-SECRET\n discord: [\n"); + var error = Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", malformed])); + Assert.DoesNotContain("TOP-SECRET", error.Message); + Assert.Throws(() => YamlConfiguration.Load(Path.Combine(_directory, "missing.yaml"), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("", includeRequired: false), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("---\nchiv2-path: .\nplatform: Steam\n"), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("platform: Steam", includeRequired: false), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("chiv2-path: .", includeRequired: false), false)); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path"])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path="])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path=x", "--config-path=y"])); + Assert.Throws(() => CliLaunchOptions.Parse(["--config-path", Write("")])); + } + + [Fact] + public void DirectClientCommandHasNoServerOptions() { + var options = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "EGS"]); + Assert.IsType(options); + Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsNone); + Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + } + + [Theory] + [InlineData("Steam", InstallationType.Steam)] + [InlineData("EGS", InstallationType.EpicGamesStore)] + [InlineData("EpicGamesStore", InstallationType.EpicGamesStore)] + public void YamlUsesCoreInstallationTypeAndPreservesEgsAlias(string platform, InstallationType expected) { + var path = Write($"chiv2-path: .\nplatform: {platform}\nupdate-plugin: false\nallow-unstable-plugin: true\n", includeRequired: false); + foreach (var command in new[] { "server", "client" }) { + var options = CliLaunchOptions.Parse([command, "--config-path", path]); + Assert.Equal(expected, options.Shared.Platform); + Assert.False(options.Shared.UpdatePlugin); + Assert.True(options.Shared.AllowUnstablePlugin); + } + } + + [Theory] + [InlineData("NotSet")] + [InlineData("1")] + [InlineData("Unknown")] + public void PlaceholderAndNumericPlatformsAreNotLaunchable(string platform) { + Assert.Throws(() => CliLaunchOptions.Parse(["client", "--chivalry2-path", ".", "--platform", platform])); + var path = Write($"chiv2-path: .\nplatform: {platform}\n", includeRequired: false); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", path])); + } + + [Fact] + public void EmptyOptionalServerFieldsAreAllowed() { + var options = Load("server: {description: '', password: '', local-ip: '', discord: {bot-token: '', general-channel-id: ''}}"); + Assert.Equal("", Assert.IsType(options).Settings.Description); + Assert.True(Assert.IsType(options).ToServerLaunchOptions().Password.IsNone); + Assert.True(Assert.IsType(options).ToDiscordOptions().IsNone); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool client) { + var options = Load(client ? "client: {}\n" : "no-tui: true\n", client); + using var child = Process.Start(new ProcessStartInfo { + FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sleep", + Arguments = OperatingSystem.IsWindows() ? "/c ping -n 30 127.0.0.1 > nul" : "30", + UseShellExecute = false, + CreateNoWindow = true + })!; + using var stop = new CancellationTokenSource(); + stop.Cancel(); + try { + Assert.Equal(0, await Program.SuperviseAsync(options, child, stop)); + Assert.Equal(!client, child.HasExited); + } + finally { + if (!child.HasExited) { + child.Kill(entireProcessTree: true); + await child.WaitForExitAsync(); + } + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CliCommandOptions.cs b/UnchainedLauncher.CLI/CliCommandOptions.cs new file mode 100644 index 00000000..70f968ef --- /dev/null +++ b/UnchainedLauncher.CLI/CliCommandOptions.cs @@ -0,0 +1,6 @@ +namespace UnchainedLauncher.CLI; + +public abstract class CliCommandOptions { + public bool Help { get; protected init; } + public string HelpText { get; protected init; } = ""; +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs new file mode 100644 index 00000000..85b59f26 --- /dev/null +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI; + +/// Settings used by both client and server launches. +public sealed class SharedLaunchOptions { + public string Chivalry2Path { get; } + public string SavedDirSuffix { get; } + public InstallationType Platform { get; } + public string Backend { get; } + public bool UpdatePlugin { get; } + public bool AllowUnstablePlugin { get; } + public bool UnsafeLoadAllDlls { get; private set; } + internal void EnableUnsafeDllLoading() => UnsafeLoadAllDlls = true; + public RegistryOptions RegistryOptions { get; } + public IReadOnlyList Registries => RegistryOptions.Registries; + public IReadOnlyList Mods { get; } + public IReadOnlyList Dlls { get; } + public IReadOnlyList GameArguments { get; } + internal ProfileAssetPaths Assets { get; set; } + + internal SharedLaunchOptions(LaunchConfiguration configuration, string directory, string? savedDirSuffix = null, ServerSettings? server = null) { + Chivalry2Path = Path.GetFullPath(configuration.Chiv2Path!, directory); + SavedDirSuffix = savedDirSuffix ?? server?.SavedDirSuffix ?? (configuration as ClientConfiguration)?.SavedDirSuffix ?? "UnchainedCLI"; + Assets = new ProfileAssetPaths(SavedDirSuffix); + Platform = configuration.Platform!.Value; + Backend = configuration.ServerBrowserBackend; + UpdatePlugin = configuration.UpdatePlugin; + AllowUnstablePlugin = configuration.AllowUnstablePlugin; + GameArguments = Entries(configuration.GameArgs, allowEmpty: true).ToArray(); + var setup = server?.ModSetup ?? (configuration as ClientConfiguration)?.ModSetup ?? new ModSetupConfiguration(); + RegistryOptions = new RegistryOptions(setup.Registries, directory); + Mods = Entries(setup.Mods).Select(ParseCoordinates).ToArray(); + Dlls = Entries(setup.Dlls).Select(ParseCoordinates).ToArray(); + if (!Uri.TryCreate(Backend, UriKind.Absolute, out var backend) || backend.Scheme is not ("http" or "https")) + throw new ArgumentException("server-browser-backend must be an HTTP(S) URL."); + if (Mods.GroupBy(m => (m.Org, m.ModuleName)).Any(g => g.Select(m => m.Version).Distinct().Count() > 1)) + throw new ArgumentException("Only one version of each mod may be selected."); + } + + internal static IEnumerable Entries(IEnumerable? values, bool allowEmpty = false) => + (values ?? []).Select(value => value is null || !allowEmpty && string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Configuration lists must contain strings, not null or blank entries.") : value); + + public static ReleaseCoordinates ParseCoordinates(string value) { + var match = Regex.Match(value, @"\A([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+):([^\s:/\\]+)\z"); + if (!match.Success) throw new ArgumentException($"Invalid coordinate '{value}'; expected organization/id:version (exact release tag)."); + return new ReleaseCoordinates(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value); + } +} + +public abstract class CliLaunchOptions : CliCommandOptions { + private readonly SharedLaunchOptions? _shared; + public SharedLaunchOptions Shared => _shared ?? throw new InvalidOperationException("Help is not a launch request."); + public IReadOnlyList GameArguments { get; } + public ServerInstancePaths InstancePaths => new(Shared.Assets); + + private protected CliLaunchOptions(SharedLaunchOptions shared, IEnumerable gameArguments) { + _shared = shared; + GameArguments = shared.GameArguments.Concat(gameArguments).ToArray(); + } + + private protected CliLaunchOptions(string helpText) { + Help = true; + HelpText = helpText; + GameArguments = []; + } + + public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse(args) as CliLaunchOptions + ?? throw new ArgumentException("Expected a client or server launch command."); + + internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configuration, string directory) { + return configuration switch { + ServerConfiguration server => new ServerOptions( + new SharedLaunchOptions(configuration, directory, server: server.Server), + server.Server ?? new ServerSettings(), server.NoTui), + ClientConfiguration client => new ClientOptions(new SharedLaunchOptions(configuration, directory), SharedLaunchOptions.Entries(client.Client?.GameArgs, allowEmpty: true)), + _ => throw new ArgumentException("Expected client or server configuration.") + }; + } + + public abstract LaunchOptions ToLaunchOptions(); + + private protected LaunchOptions BuildLaunchOptions(IEnumerable prefix, LanguageExt.Option server) { + return new( + EnabledReleases: Shared.Mods.Distinct(), ServerBrowserBackend: Some(Shared.Backend), + LaunchArgs: string.Join(" ", prefix.Concat(GameArguments.Select(ArgumentEscaper.Escape))), + CheckForDependencyUpdates: Shared.UpdatePlugin, AllowUnstablePluginReleases: Shared.AllowUnstablePlugin, + SavedDirSuffix: Some(Shared.SavedDirSuffix), ServerLaunchOptions: server); + } + + internal static CliLaunchOptions ForHelp(string text) => new HelpOptions(text); + + private sealed class HelpOptions : CliLaunchOptions { + // Help is a parser result, never a launch; shared settings are not constructed. + public HelpOptions(string text) : base(text) { } + public override LaunchOptions ToLaunchOptions() => throw new InvalidOperationException("Help is not a launch request."); + } +} + +public sealed class ClientOptions : CliLaunchOptions { + internal ClientOptions(SharedLaunchOptions shared, IEnumerable arguments) : base(shared, arguments) { } + public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL"], None); +} + +public sealed class ServerOptions : CliLaunchOptions { + internal IniOverrides Ini { get; } + public ServerSettings Settings { get; } + public bool NoTui { get; } + // Chivalry removes hyphens when choosing its Saved directory (the plugin log path does not). + internal string GameSavedDirSuffix => Shared.Assets.Suffix; + + internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool noTui) + : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { + Settings = settings; + Ini = IniOverrides.Parse(settings.Ini); + if (settings.Rotation is not null && Ini.Contains("Game", "/Script/TBL.TBLGameMode", "MapListIndex")) + throw new ArgumentException("MapListIndex is managed by map-rotation; use starting-map to choose the first map."); + NoTui = noTui; + if (!settings.Headless && settings.DesyncPatch) + throw new ArgumentException("Desync patch requires a headless server. To enable rendering, pass --no-desync-patch or set server.desync-patch: false in YAML."); + if (new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }.Distinct().Count() != 4) + throw new ArgumentException("Game, ping, query, and RCON ports must be distinct."); + settings.LocalIp = string.IsNullOrWhiteSpace(settings.LocalIp) ? LocalAddressDetector.Detect().ToString() : settings.LocalIp.Trim(); + if (!IPAddress.TryParse(settings.LocalIp, out var address) || address.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("local-ip must be an IPv4 address."); + settings.ModActors = SharedLaunchOptions.Entries(settings.ModActors).Distinct().ToList(); + ToDiscordOptions(); + } + + public LanguageExt.Option ToDiscordOptions() { + if (Settings.Discord is not { } discord) return None; + var channels = new[] { discord.AdminChannelId, discord.GeneralChannelId, discord.DashboardChannelId, discord.EventLogChannelId }; + if (string.IsNullOrWhiteSpace(discord.BotToken) && channels.All(string.IsNullOrWhiteSpace) && string.IsNullOrWhiteSpace(discord.AdminRoleId)) return None; + if (string.IsNullOrWhiteSpace(discord.BotToken) || channels.All(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Discord integration requires a bot token and at least one channel ID."); + LanguageExt.Option Text(string? value) => string.IsNullOrWhiteSpace(value) ? None : Some(value.Trim()); + return Some(new DiscordIntegrationLaunchOptions(discord.BotToken.Trim(), + Text(discord.AdminChannelId), Text(discord.GeneralChannelId), Text(discord.DashboardChannelId), + Text(discord.EventLogChannelId), Text(discord.AdminRoleId), discord.MentionAdmins)); + } + + public ServerLaunchOptions ToServerLaunchOptions() => new( + Settings.Headless, Settings.Name, Settings.Description, RegisterWithBackend: Settings.Register, + Password: string.IsNullOrWhiteSpace(Settings.Password) ? None : Some(Settings.Password.Trim()), + Settings.StartingMap, Settings.GamePort, BeaconPort: Settings.PingPort, Settings.QueryPort, Settings.RconPort, + FFATimeLimit: null, FFAScoreLimit: null, TDMTimeLimit: null, TDMTicketCount: null, + PlayerBotCount: null, WarmupTime: null, LocalIp: Optional(Settings.LocalIp), + ServerMods: Settings.ModActors ?? [], DiscordIntegration: ToDiscordOptions(), + Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode) { + SuppressServerNameOverride = Ini.Contains("Game", "/Script/TBL.TBLGameMode", "ServerName") + }; + + public override LaunchOptions ToLaunchOptions() { + var prefix = new List { "TBL", "-server" }; + if (!Ini.Contains("Game", "/Script/TBL.TBLTitleScreen", "bSavedHasAgreedToTOS")) + prefix.Add("-ini:Game:[/Script/TBL.TBLTitleScreen]:bSavedHasAgreedToTOS=True"); + if (!Ini.Contains("Game", "/Script/TBL.TBLGameInstance", "FirstLoadCompleted")) + prefix.Add("-ini:Game:[/Script/TBL.TBLGameInstance]:FirstLoadCompleted=True"); + prefix.AddRange(Ini.RawArguments); + return BuildLaunchOptions(prefix, Some(ToServerLaunchOptions())); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs new file mode 100644 index 00000000..77009710 --- /dev/null +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -0,0 +1,197 @@ +using System.CommandLine; +using System.CommandLine.Help; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Processes; + +namespace UnchainedLauncher.CLI; + +internal static class CommandLineOptions { + public static CliCommandOptions Parse(string[] args) => Parse(args, static (root, tokens, configuration) => root.Parse(tokens, configuration)); + + // The delegate is the single library-parse boundary, also exercised by tests. + internal static CliCommandOptions Parse(string[] args, Func parse) { + var separator = Array.IndexOf(args, "--"); + var launcherArgs = separator < 0 ? args : args[..separator]; + var gameArgs = separator < 0 ? [] : args[(separator + 1)..]; + var root = new RootCommand("Unchained client/server launcher and mod registry queries. Only launching and DLL injection require Windows."); + var config = new Option("--config-path") { + Description = "Launch YAML overrides launch options; mod-query reads its registry settings.", + Arity = ArgumentArity.ExactlyOne, + Recursive = true + }; + root.Options.Add(config); + var unsafeDlls = UnsafeDllLoading.CreateOption(recursive: true); + root.Options.Add(unsafeDlls); + var server = new Definition(client: false); + var client = new Definition(client: true); + root.Subcommands.Add(server.Command); + root.Subcommands.Add(client.Command); + var queries = new ModQueryDefinition(); + root.Subcommands.Add(queries.Command); + var dllQueries = new ModQueryDefinition(RegistryPackageKind.Dll); + root.Subcommands.Add(dllQueries.Command); + // Never interpret @values as file reads, including ignored YAML overrides. + // Response-file expansion is deliberately disabled for all launcher input. + var parsed = parse(root, launcherArgs, new ParserConfiguration { ResponseFileTokenReplacer = null }); + var loadAllDlls = UnsafeDllLoading.Read(parsed, unsafeDlls, launcherArgs); + var definition = parsed.CommandResult.Command == server.Command ? server : client; + var isQuery = queries.Contains(parsed.CommandResult.Command) || dllQueries.Contains(parsed.CommandResult.Command); + if (!isQuery && parsed.GetResult(config) is { } configResult) { + if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); + // Help actions skip library validators. Check the parsed selector itself + // so --help cannot bypass its singleton/non-empty contract either. + if (configResult.IdentifierTokenCount != 1 || configResult.Tokens.Count != 1 || string.IsNullOrWhiteSpace(configResult.Tokens[0].Value)) + throw new ArgumentException("Specify exactly one non-empty --config-path."); + // All non-selector conversion, required-option and unknown-token errors + // are irrelevant when YAML is authoritative; do not bind those values. + var errors = parsed.Errors.Where(error => error.SymbolResult == configResult); + ThrowErrors(errors.Select(error => error.Message)); + var configured = YamlConfiguration.Load(parsed.GetValue(config)!, definition.Command == client.Command); + if (loadAllDlls) { + if (configured is CliLaunchOptions single) single.Shared.EnableUnsafeDllLoading(); + if (configured is ServerGroupOptions group) + foreach (var entry in group.Servers) entry.Options.Shared.EnableUnsafeDllLoading(); + } + return configured; + } + if (args.Length == 0 || parsed.GetResult("--help") is not null) { + using var output = new StringWriter(); + var help = new HelpAction(); + parsed.InvocationConfiguration.Output = output; + help.Invoke(parsed); + return CliLaunchOptions.ForHelp(output.ToString()); + } + ThrowErrors(parsed); + if (isQuery) { + if (loadAllDlls) throw new ArgumentException("Unsafe DLL loading is only valid for launch commands."); + if (separator >= 0) throw new ArgumentException("mod-query does not accept forwarded game arguments."); + return (dllQueries.Contains(parsed.CommandResult.Command) ? dllQueries : queries).ToOptions(parsed, config); + } + if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); + var launch = definition.ToOptions(parsed, gameArgs); + if (loadAllDlls) launch.Shared.EnableUnsafeDllLoading(); + return launch; + } + + private static void ThrowErrors(ParseResult result) { + ThrowErrors(result.Errors.Select(error => error.Message)); + } + + private static void ThrowErrors(IEnumerable errors) { + var messages = errors.ToArray(); + if (messages.Length != 0) throw new ArgumentException(string.Join(Environment.NewLine, messages)); + } + + private sealed class Definition { + public Command Command { get; } + private readonly LaunchConfiguration _configuration; + private readonly List> _bindings = []; + private readonly Argument _gameArgs = new("game-args") { Arity = ArgumentArity.ZeroOrMore }; + + public Definition(bool client) { + Command = new Command(client ? "client" : "server", client ? "Launch an Unchained client." : "Launch and monitor an Unchained server."); + _configuration = client ? new ClientConfiguration() : new ServerConfiguration { Server = new ServerSettings() }; + Add("--chivalry2-path", "Game installation directory.", null, value => _configuration.Chiv2Path = value, required: true); + var platform = new Option("--platform") { + Description = "Steam or EGS (EpicGamesStore); EGS auth must be forwarded after --.", + Arity = ArgumentArity.ExactlyOne, + Required = true, + CustomParser = result => { + if (result.Tokens.Count != 1) { result.AddError("Specify --platform once."); return null; } + try { return InstallationTypeYamlConverter.Parse(result.Tokens[0].Value); } + catch (ArgumentException ex) { result.AddError(ex.Message); return null; } + } + }; + Command.Options.Add(platform); + _bindings.Add(result => _configuration.Platform = result.GetValue(platform)); + var clientConfig = _configuration as ClientConfiguration; + var serverSettings = (_configuration as ServerConfiguration)?.Server; + Add("--saved-dir-suffix", "Saved profile suffix.", clientConfig?.SavedDirSuffix ?? "UnchainedCLI", value => { + if (clientConfig is not null) clientConfig.SavedDirSuffix = value; + else serverSettings!.SavedDirSuffix = value; + }); + Add("--server-browser-backend", "HTTP(S) server-browser backend URL.", _configuration.ServerBrowserBackend, value => _configuration.ServerBrowserBackend = value); + Flag("--no-plugin-update", "Disable DLL downloads; required registered versions must be installed or cached (use --dll to pin).", value => _configuration.UpdatePlugin = !value); + Flag("--allow-unstable-plugin", "Allow prerelease plugin updates.", value => _configuration.AllowUnstablePlugin = value); + var setup = new ModSetupConfiguration(); + if (clientConfig is not null) clientConfig.ModSetup = setup; + else serverSettings!.ModSetup = setup; + var registries = RegistryOptions.AddOption(Command); + _bindings.Add(result => setup.Registries = [.. result.GetValue(registries) ?? []]); + Many("--mod", "Install organization/id:version (exact release tag).", value => setup.Mods = value); + Many("--dll", "Select a registry DLL release: organization/id:version (exact release tag).", value => setup.Dlls = value); + if (_configuration is ServerConfiguration serverConfig) { + var settings = serverConfig.Server!; + Add("--name", "Server name.", settings.Name, value => settings.Name = value); + Add("--description", "Description / MOTD.", settings.Description, value => settings.Description = value); + Add("--starting-map", "Initial map.", settings.StartingMap, value => settings.StartingMap = value); + Add("--password", "Server password.", null, value => settings.Password = value); + Add("--local-ip", "Override auto-detected local IPv4 (VPN/multi-NIC).", null, value => settings.LocalIp = value); + Add("--game-port", "Game port.", settings.GamePort, value => settings.GamePort = value); + Add("--ping-port", "Ping port.", settings.PingPort, value => settings.PingPort = value); + Add("--query-port", "A2S query port.", settings.QueryPort, value => settings.QueryPort = value); + Add("--rcon-port", "RCON port.", settings.RconPort, value => settings.RconPort = value); + Add("--headless", "Run without rendering (true/false). Rendering requires --no-desync-patch.", settings.Headless, value => settings.Headless = value); + Add("--censor-mode", "none, standard, sex, or zealous.", settings.CensorMode, value => settings.CensorMode = value); + Flag("--register", "List in the server browser.", value => settings.Register = value); + Flag("--no-tui", "Periodic plain-text status instead of dashboard.", value => serverConfig.NoTui = value); + Flag("--no-desync-patch", "Disable the default-enabled desync patch.", value => settings.DesyncPatch = !value); + Flag("--no-backend-banlist", "Disable the default-enabled backend banlist.", value => settings.UseBackendBanlist = !value); + Many("--server-mod", "Activate a blueprint class (separate from pak installation).", value => settings.ModActors = value); + var discord = new DiscordSettings(); + settings.Discord = discord; + Add("--discord-bot-token", "Bot token; requires at least one channel.", null, value => discord.BotToken = value); + Add("--discord-admin-channel-id", "Admin channel.", null, value => discord.AdminChannelId = value); + Add("--discord-general-channel-id", "General channel.", null, value => discord.GeneralChannelId = value); + Add("--discord-dashboard-channel-id", "Dashboard channel.", null, value => discord.DashboardChannelId = value); + Add("--discord-event-log-channel-id", "Event-log channel.", null, value => discord.EventLogChannelId = value); + Add("--discord-admin-role-id", "Admin role.", null, value => discord.AdminRoleId = value); + Add("--discord-mention-admins", "Mention admins (true/false).", true, value => discord.MentionAdmins = value); + } + // Only tokens following -- are game arguments, never misspelled options. + Command.Arguments.Add(_gameArgs); + Command.Validators.Add(result => { + if (result.GetResult(_gameArgs) is { Tokens.Count: > 0 }) + result.AddError("Pass game arguments after --."); + }); + } + + private void Add(string name, string description, T defaultValue, Action bind, bool required = false) { + var option = new Option(name) { + Description = description, + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = _ => defaultValue, + Required = required + }; + option.Validators.Add(result => { + if (result.Tokens.Count > 1) result.AddError($"Duplicate option: {name}"); + if (result.Tokens.Any(token => string.IsNullOrWhiteSpace(token.Value))) result.AddError($"Empty value for {name}."); + }); + Command.Options.Add(option); + _bindings.Add(result => bind(result.GetValue(option)!)); + } + + private void Flag(string name, string description, Action bind) { + var option = new Option(name) { Description = description, Arity = ArgumentArity.Zero }; + option.Validators.Add(result => { + if (result.IdentifierTokenCount > 1) result.AddError($"Duplicate option: {name}"); + }); + Command.Options.Add(option); + _bindings.Add(result => bind(result.GetValue(option))); + } + + private void Many(string name, string description, Action> bind) { + var option = new Option(name) { Description = description, Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false }; + Command.Options.Add(option); + _bindings.Add(result => bind([.. result.GetValue(option) ?? []])); + } + + public CliLaunchOptions ToOptions(ParseResult parsed, string[] gameArgs) { + foreach (var bind in _bindings) bind(parsed); + _configuration.GameArgs = [.. gameArgs]; + try { _configuration.Validate(); } + catch (System.ComponentModel.DataAnnotations.ValidationException ex) { throw new ArgumentException(ex.Message); } + return CliLaunchOptions.FromConfiguration(_configuration, Directory.GetCurrentDirectory()); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ConsoleDialogue.cs b/UnchainedLauncher.CLI/ConsoleDialogue.cs new file mode 100644 index 00000000..7b216fc5 --- /dev/null +++ b/UnchainedLauncher.CLI/ConsoleDialogue.cs @@ -0,0 +1,26 @@ +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.CLI; + +// The CLI is non-interactive during preparation: repair bad hashes, accept the +// explicitly enabled plugin update policy, but never continue a failed install. +public sealed class ConsoleDialogue : IUserDialogueSpawner { + public void DisplayMessage(string message) => Console.Error.WriteLine(message); + + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) { + DisplayMessage($"{caption}: {message}"); + return caption == "Refresh pak file?" ? UserDialogueChoice.Yes : UserDialogueChoice.No; + } + + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, + string yesButtonText, string noButtonText, string? cancelButtonText, IEnumerable updates) { + foreach (var update in updates) Console.Error.WriteLine($"Updating {update.Name} to {update.LatestVersion}."); + return UserDialogueChoice.Yes; + } + + public Action DisplayProgress(MemoryProgress progress) { + Console.Error.WriteLine("Preparing mod files..."); + return () => { }; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/DllQuery.cs b/UnchainedLauncher.CLI/DllQuery.cs new file mode 100644 index 00000000..10d8059b --- /dev/null +++ b/UnchainedLauncher.CLI/DllQuery.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using UnchainedLauncher.Core.Services.Mods.Registry; +using static UnchainedLauncher.CLI.ModQuery; + +namespace UnchainedLauncher.CLI; + +public static class DllQuery { + public static async Task RunAsync(ModQueryOptions options, AggregateModRegistry registry, TextWriter output, TextWriter error, CancellationToken token) { + token.ThrowIfCancellationRequested(); + var catalog = await DllCatalog.ReadAsync(registry, token); + foreach (var problem in catalog.Errors) await error.WriteLineAsync(problem); + if (options.Operation == ModQueryOperation.List) { + await output.WriteLineAsync("DLL ID\tNAME\tLATEST VERSION"); + foreach (var package in catalog.Packages) + await output.WriteLineAsync($"{Id(package.LatestReleaseInfo.Identifier)}\t{Line(package.LatestReleaseInfo.Name)}\t{Line(package.LatestRelease?.Tag ?? "none")}"); + return catalog.Errors.Count == 0 ? 0 : 1; + } + var found = catalog.Packages.FirstOrDefault(package => options.Mod!.Matches(package.LatestReleaseInfo.Identifier)); + if (found is null) { + await error.WriteLineAsync($"DLL not found or registry unavailable: {Id(options.Mod!)}"); + return 1; + } + if (options.Operation == ModQueryOperation.Versions) { + await output.WriteLineAsync("VERSION\tRELEASE DATE\tTARGET"); + foreach (var release in found.Releases.OrderByDescending(release => release.Version).ThenByDescending(release => release.ReleaseDate)) + await output.WriteLineAsync($"{Line(release.Tag)}\t{release.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}\t{Line(release.Info.Target)}"); + } + else { + var selected = options.Version is null ? found.LatestRelease : found.Releases.Find(release => release.Tag == options.Version); + if (selected is null) { + await error.WriteLineAsync($"DLL release not found: {Id(options.Mod!)}:{options.Version ?? "latest"}"); + return 1; + } + await output.WriteLineAsync($"DLL ID: {Id(options.Mod!)}\nName: {Line(selected.Info.Name)}\nVersion: {Line(selected.Tag)}\nDescription: {Line(selected.Info.Description)}\nTarget: {Line(selected.Info.Target)}"); + await output.WriteLineAsync($"Authors: {string.Join(", ", selected.Info.Authors.Select(Line))}\nRepository: {Line(selected.Info.RepoUrl)}\nRelease date: {selected.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}"); + await output.WriteLineAsync($"DLL file: {Line(selected.DllFileName)}\nHash (SHA-256): {Line(selected.Hash)}\nSize: {selected.Size.ToString(CultureInfo.InvariantCulture)} bytes\nDownload URL: {Line(selected.DownloadUrl)}"); + await output.WriteLineAsync($"Plugin dependencies ({selected.Info.PluginDependencies.Count}):"); + foreach (var dependency in selected.Info.PluginDependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Mod dependencies ({selected.Info.ModDependencies.Count}):"); + foreach (var dependency in selected.Info.ModDependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Release notes: {Line(selected.ReleaseNotesMarkdown ?? "none")}"); + } + return catalog.Errors.Count == 0 ? 0 : 1; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/IgnoredYamlNodeConverter.cs b/UnchainedLauncher.CLI/IgnoredYamlNodeConverter.cs new file mode 100644 index 00000000..01628551 --- /dev/null +++ b/UnchainedLauncher.CLI/IgnoredYamlNodeConverter.cs @@ -0,0 +1,18 @@ +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +internal sealed class IgnoredYamlNodeConverter : IYamlTypeConverter { + // Opt in only via YamlConverter on the opposite command's property, never by type. + public bool Accepts(Type type) => false; + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + // Consume one syntactically valid YAML node without materializing or validating its contents. + parser.SkipThisAndNestedEvents(); + return null; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + throw new NotSupportedException("Ignored YAML nodes are only used when loading configuration."); +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/IniOverrides.cs b/UnchainedLauncher.CLI/IniOverrides.cs new file mode 100644 index 00000000..a2297037 --- /dev/null +++ b/UnchainedLauncher.CLI/IniOverrides.cs @@ -0,0 +1,179 @@ +using StructuredINI; +using StructuredINI.Codecs; +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.INIModels; +using UnchainedLauncher.Core.Services.Processes.Chivalry; + +namespace UnchainedLauncher.CLI; + +internal sealed class IniOverrides { + private sealed record Entry(string File, string Section, string Key, string[] Values, bool Array, bool Raw); + private readonly List _entries = []; + private static readonly Type[] ScalarTypes = [typeof(string), typeof(bool), typeof(int), typeof(float), typeof(double), typeof(decimal)]; + private static readonly Type[] MappedTypes = ScalarTypes.Concat(typeof(ClassLimit).Assembly.GetTypes() + .Where(type => type.IsDefined(typeof(DeriveCodecAttribute), false))).Append(typeof(CallbackList)).ToArray(); + + internal IEnumerable RawArguments => _entries.Where(entry => entry.Raw) + .Select(entry => new UEINIParameter(entry.File, entry.Section, entry.Key, entry.Values[0]).Rendered); + + internal bool Contains(string file, string section, string key) => _entries.Any(entry => + string.Equals(entry.File, file, StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.Section, section, StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.Key, key, StringComparison.OrdinalIgnoreCase)); + + internal static IniOverrides Parse(Dictionary>>? files) { + var result = new IniOverrides(); + if (files is null) return result; + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (file, sections) in files) { + if (!Regex.IsMatch(file, @"\A[A-Za-z][A-Za-z0-9_]*\z")) + throw new ArgumentException("INI file names must be bare categories such as Game, without .ini or a path."); + if (sections is null) throw new ArgumentException($"ini.{file} must contain a section mapping."); + foreach (var (section, fields) in sections) { + ValidateName(section, "section"); + if (fields is null) throw new ArgumentException($"ini.{file}.{section} must contain a field mapping."); + foreach (var (key, node) in fields) { + if (key.TrimStart('!', '+', '-', '.').Equals("MapList", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("MapList cannot be set in ini; use server.map-rotation (or each servers entry's map-rotation) instead."); + ValidateName(key, "key"); + var path = $"ini.{file}.{section}.{key}"; + if (!paths.Add($"{file}\0{section}\0{key}")) throw new ArgumentException($"Duplicate INI field: {path} (names are case-insensitive)."); + try { result._entries.Add(ParseEntry(file, section, key, node)); } + catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException or TargetInvocationException) { + // Do not echo values: INI overrides may contain credentials. + throw new ArgumentException($"Invalid {path}: {ex.Message}"); + } + } + } + } + return result; + } + + private static Entry ParseEntry(string file, string section, string key, object? node) { + var knownType = FindKnownType(file, section, key); + var type = knownType; + var value = node; + if (node is IDictionary map && (map.Contains("type") || map.Contains("value"))) { + if (map.Keys.Cast().Any(name => name is not string text || text is not ("type" or "value")) || !map.Contains("value")) + throw new ArgumentException("Use only type and value in a typed override, and supply value."); + value = map["value"]; + if (map.Contains("type")) { + if (map["type"] is not string name) throw new ArgumentException("type must be a supported type name."); + if (name.Equals("raw", StringComparison.OrdinalIgnoreCase)) { + if (value is not string raw || raw.Contains('\0')) throw new ArgumentException("raw requires a string value without NUL characters."); + return new Entry(file, section, key, [raw], false, true); + } + type = ResolveType(name); + if (knownType is not null && type != knownType) + throw new ArgumentException($"This known field requires type {knownType.Name}, or raw."); + } + } + if (type is null) throw new ArgumentException("Unknown field; supply { type: String, value: ... } (or type: raw for an exact INI string)."); + if (type.IsArray) { + if (value is not IList list) throw new ArgumentException("Array fields require a list of values."); + return new Entry(file, section, key, list.Cast().Select(item => Encode(type.GetElementType()!, item)).ToArray(), true, false); + } + return new Entry(file, section, key, [Encode(type, value)], false, false); + } + + private static Type? FindKnownType(string file, string section, string key) { + var fileProperty = typeof(Chivalry2INI).GetProperties().FirstOrDefault(property => + property.Name.Equals(file, StringComparison.OrdinalIgnoreCase)); + var sectionType = fileProperty?.PropertyType.GetProperties().Select(property => property.PropertyType).FirstOrDefault(type => + string.Equals(type.GetCustomAttribute()?.SectionName, section, StringComparison.OrdinalIgnoreCase)); + return sectionType?.GetProperties().FirstOrDefault(property => + (property.GetCustomAttribute()?.KeyName ?? property.Name).Equals(key, StringComparison.OrdinalIgnoreCase))?.PropertyType; + } + + private static Type ResolveType(string name) { + if (name.EndsWith("[]", StringComparison.Ordinal)) { + var element = ResolveType(name[..^2]); + if (element.IsArray) throw new ArgumentException("Nested INI arrays are not supported."); + return element.MakeArrayType(); + } + if (name.Equals("Bool", StringComparison.OrdinalIgnoreCase)) return typeof(bool); + if (name.Equals("Int", StringComparison.OrdinalIgnoreCase)) return typeof(int); + if (name.Equals("Float", StringComparison.OrdinalIgnoreCase)) return typeof(float); + return MappedTypes.FirstOrDefault(type => type.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException("Unsupported type. Use String, Boolean, Int32, Single, Double, Decimal, CharacterClass, ClassLimit, AutoBalance, CallbackList, an array type such as String[], or raw."); + } + + private static string Encode(Type type, object? node) { + var value = ConvertValue(type, node); + var codec = type == typeof(CallbackList) ? new CallbackListCodec() : CodecRegistry.Get(type); + var encoded = (string)codec.GetType().GetMethod("Encode")!.Invoke(codec, [value])!; + if (encoded.IndexOfAny(['\r', '\n', '\0']) >= 0) + throw new ArgumentException("Typed INI values cannot contain line breaks or NUL characters."); + return encoded; + } + + private static object ConvertValue(Type type, object? node) { + if (node is null) throw new ArgumentException("INI values cannot be null."); + if (type == typeof(CallbackList)) { + if (node is not IList callbacks) throw new ArgumentException("CallbackList requires a list of strings."); + return new CallbackList(callbacks.Cast().Select(item => (string)ConvertValue(typeof(string), item)).ToArray()); + } + if (ScalarTypes.Contains(type) || type.IsEnum) { + if (node is IDictionary or IList) throw new ArgumentException($"Expected a scalar {type.Name} value."); + var text = Convert.ToString(node, CultureInfo.InvariantCulture)!; + if (type.IsEnum) { + if (!Enum.GetNames(type).Any(name => name.Equals(text, StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException($"Expected a named {type.Name} value."); + return Enum.Parse(type, text, true); + } + try { + var codec = CodecRegistry.Get(type); + var value = codec.GetType().GetMethod("Decode")!.Invoke(codec, [text])!; + if (value is double d && !double.IsFinite(d) || value is float f && !float.IsFinite(f)) + throw new ArgumentException("Numbers must be finite."); + return value; + } + catch (TargetInvocationException) { throw new ArgumentException($"Expected a valid {type.Name} value."); } + } + var constructor = type.GetConstructors().OrderByDescending(ctor => ctor.GetParameters().Length).First(); + var parameters = constructor.GetParameters(); + object?[] args; + if (node is IDictionary fields) { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry field in fields) { + if (field.Key is not string name || !parameters.Any(parameter => parameter.Name!.Equals(name, StringComparison.OrdinalIgnoreCase)) || !values.TryAdd(name, field.Value)) + throw new ArgumentException($"Invalid or duplicate member of {type.Name}; expected {string.Join(", ", parameters.Select(parameter => parameter.Name))}."); + } + args = parameters.Select(parameter => values.TryGetValue(parameter.Name!, out var item) + ? ConvertValue(parameter.ParameterType, item) + : throw new ArgumentException($"Missing {type.Name}.{parameter.Name}.")).ToArray(); + } + else if (node is IList list && list.Count == parameters.Length) { + args = parameters.Select((parameter, index) => ConvertValue(parameter.ParameterType, list[index])).ToArray(); + } + else throw new ArgumentException($"{type.Name} requires a member mapping or a list of {parameters.Length} parameters."); + return constructor.Invoke(args); + } + + private static void ValidateName(string name, string kind) { + if (string.IsNullOrWhiteSpace(name) || name != name.Trim() || name.IndexOfAny(['[', ']', ':', '=', ',', '\r', '\n', '\0', '"']) >= 0 || + kind == "key" && "!+.-;#".Contains(name[0])) + throw new ArgumentException($"Invalid INI {kind} name."); + } + + internal void Apply(string directory) { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + BufferInto(files); + IniPatches.Write(directory, files); + } + + internal void BufferInto(Dictionary files) { + foreach (var file in _entries.Where(entry => !entry.Raw).GroupBy(entry => entry.File, StringComparer.OrdinalIgnoreCase)) { + if (!files.TryGetValue(file.Key, out var writer)) files[file.Key] = writer = new StructuredINIWriter(); + foreach (var section in file.GroupBy(entry => entry.Section, StringComparer.OrdinalIgnoreCase)) { + var lines = section.SelectMany(entry => entry.Array + ? IniPatches.Array(entry.Key, entry.Values) + : [KeyValuePair.Create(entry.Key, entry.Values[0])]); + writer.MergeSection(section.Key, lines); + } + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/IniPatches.cs b/UnchainedLauncher.CLI/IniPatches.cs new file mode 100644 index 00000000..6f33996a --- /dev/null +++ b/UnchainedLauncher.CLI/IniPatches.cs @@ -0,0 +1,16 @@ +using StructuredINI; + +namespace UnchainedLauncher.CLI; + +internal static class IniPatches { + internal static IEnumerable> Array(string key, IEnumerable values) => + new[] { KeyValuePair.Create("!" + key, "ClearArray") } + .Concat(values.Select(value => KeyValuePair.Create("." + key, value))); + + internal static void Write(string directory, Dictionary files) { + foreach (var (file, writer) in files) { + if (!writer.WriteOut(Path.Combine(directory, file + ".ini"))) + throw new IOException("Could not apply server INI profile; refusing to launch."); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs b/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs new file mode 100644 index 00000000..b7cf2576 --- /dev/null +++ b/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs @@ -0,0 +1,25 @@ +using UnchainedLauncher.Core.JsonModels; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +internal sealed class InstallationTypeYamlConverter : IYamlTypeConverter { + public bool Accepts(Type type) => type == typeof(InstallationType) || type == typeof(InstallationType?); + + internal static InstallationType Parse(string value) => value.ToLowerInvariant() switch { + "steam" => InstallationType.Steam, + "egs" or "epicgamesstore" => InstallationType.EpicGamesStore, + _ => throw new ArgumentException("Platform must be Steam or EGS (EpicGamesStore).") + }; + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + var scalar = parser.Consume(); + try { return Parse(scalar.Value); } + catch (ArgumentException) { throw new YamlException(scalar.Start, scalar.End, "Invalid platform."); } + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + emitter.Emit(new Scalar(value?.ToString() ?? "null")); +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/LaunchConfiguration.cs b/UnchainedLauncher.CLI/LaunchConfiguration.cs new file mode 100644 index 00000000..25c33a94 --- /dev/null +++ b/UnchainedLauncher.CLI/LaunchConfiguration.cs @@ -0,0 +1,128 @@ +using System.ComponentModel.DataAnnotations; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +public abstract class LaunchConfiguration { + [Required] + public string? Chiv2Path { get; set; } + + [Required, EnumDataType(typeof(InstallationType))] + public InstallationType? Platform { get; set; } + + public string ServerBrowserBackend { get; set; } = "https://servers.polehammer.net"; + public bool UpdatePlugin { get; set; } = true; + public bool AllowUnstablePlugin { get; set; } + public List? GameArgs { get; set; } = []; + + public virtual void Validate() { + Validator.ValidateObject(this, new ValidationContext(this), validateAllProperties: true); + if (Platform is not (InstallationType.Steam or InstallationType.EpicGamesStore)) + throw new ValidationException("Platform must be Steam or EGS (EpicGamesStore)."); + } +} + +public sealed class ServerConfiguration : LaunchConfiguration { + public Dictionary? MapProfiles { get; set; } + public bool NoTui { get; set; } + public ServerSettings? Server { get; set; } + public Dictionary? Servers { get; set; } + // Recognized but skipped: do not deserialize or validate the inactive schema. + [YamlConverter(typeof(IgnoredYamlNodeConverter))] + public object? Client { get; set; } + + public override void Validate() { + base.Validate(); + foreach (var (name, profile) in MapProfiles ?? []) { + if (string.IsNullOrWhiteSpace(name) || name != name.Trim()) throw new ValidationException("map-profiles keys must not be blank or have surrounding whitespace."); + MapRotation.ValidateMap(profile, $"map-profiles.{name}"); + } + if (Server is not null) ValidateServer(Server); + if (Servers is not null) { + if (Server is not null) throw new ValidationException("Use either server or servers, not both."); + if (Servers.Count == 0) throw new ValidationException("servers must contain at least one named server."); + foreach (var settings in Servers.Values) { + if (settings is null) throw new ValidationException("Each servers entry must contain server settings."); + ValidateServer(settings); + } + } + } + + private void ValidateServer(ServerSettings settings) { + Validator.ValidateObject(settings, new ValidationContext(settings), validateAllProperties: true); + settings.Rotation = MapRotation.Resolve(settings.MapRotation, MapProfiles, settings.ExplicitStartingMap); + } +} + +public sealed class ClientConfiguration : LaunchConfiguration { + [YamlConverter(typeof(IgnoredYamlNodeConverter))] + public object? MapProfiles { get; set; } + [Required] + public string SavedDirSuffix { get; set; } = UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths.SharedSuffix; + public ModSetupConfiguration? ModSetup { get; set; } + public ClientSettings? Client { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] + public object? Server { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] + public object? Servers { get; set; } +} + +public sealed class ModSetupConfiguration { + public List? Registries { get; set; } = []; + public List? Mods { get; set; } = []; + public List? Dlls { get; set; } = []; +} + +public sealed class ClientSettings { + public List? GameArgs { get; set; } = []; +} + +public sealed class ServerSettings { + public List? MapRotation { get; set; } + [YamlIgnore] + internal MapRotation? Rotation { get; set; } + [YamlIgnore] + internal string? ExplicitStartingMap { get; private set; } + public ModSetupConfiguration? ModSetup { get; set; } + public Dictionary>>? Ini { get; set; } + public string? SavedDirSuffix { get; set; } + [Required] + public string Name { get; set; } = "Unchained Server"; + public string Description { get; set; } = ""; + public string? Password { get; set; } + [Required] + public string StartingMap { + get => Rotation?.Maps[Rotation.StartIndex] ?? ExplicitStartingMap ?? "FFA_Courtyard"; + set => ExplicitStartingMap = value; + } + public string? LocalIp { get; set; } + [Range(1, 65535)] + public int GamePort { get; set; } = 7777; + [Range(1, 65535)] + public int PingPort { get; set; } = 3075; + [Range(1, 65535)] + public int QueryPort { get; set; } = 7071; + [Range(1, 65535)] + public int RconPort { get; set; } = 9001; + public bool Headless { get; set; } = true; + public bool Register { get; set; } + public bool DesyncPatch { get; set; } = true; + public bool UseBackendBanlist { get; set; } = true; + [EnumDataType(typeof(CensorArg))] + public CensorArg CensorMode { get; set; } = CensorArg.None; + public List? ModActors { get; set; } = []; + public List? GameArgs { get; set; } = []; + public DiscordSettings? Discord { get; set; } +} + +public sealed class DiscordSettings { + public string? BotToken { get; set; } + public string? AdminChannelId { get; set; } + public string? GeneralChannelId { get; set; } + public string? DashboardChannelId { get; set; } + public string? EventLogChannelId { get; set; } + public string? AdminRoleId { get; set; } + public bool MentionAdmins { get; set; } = true; +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/LocalAddressDetector.cs b/UnchainedLauncher.CLI/LocalAddressDetector.cs new file mode 100644 index 00000000..ea36d567 --- /dev/null +++ b/UnchainedLauncher.CLI/LocalAddressDetector.cs @@ -0,0 +1,32 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace UnchainedLauncher.CLI; + +public static class LocalAddressDetector { + public static IPAddress Detect() { + try { + var addresses = NetworkInterface.GetAllNetworkInterfaces() + .Where(adapter => adapter.OperationalStatus == OperationalStatus.Up + && adapter.NetworkInterfaceType is not (NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel)) + .SelectMany(adapter => { + var properties = adapter.GetIPProperties(); + var hasGateway = properties.GatewayAddresses.Any(g => !g.Address.Equals(IPAddress.Any) + && g.Address.AddressFamily == AddressFamily.InterNetwork); + return properties.UnicastAddresses.Select(ip => (ip.Address, HasGateway: hasGateway)); + }); + return Select(addresses); + } + catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException or SocketException) { + return IPAddress.Loopback; + } + } + + internal static IPAddress Select(IEnumerable<(IPAddress Address, bool HasGateway)> candidates) => + candidates.Where(candidate => candidate.Address.AddressFamily == AddressFamily.InterNetwork + && !candidate.Address.Equals(IPAddress.Any) && !IPAddress.IsLoopback(candidate.Address) + && !(candidate.Address.GetAddressBytes()[0] == 169 && candidate.Address.GetAddressBytes()[1] == 254)) + .OrderByDescending(candidate => candidate.HasGateway) + .Select(candidate => candidate.Address).FirstOrDefault() ?? IPAddress.Loopback; +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/MapRotation.cs b/UnchainedLauncher.CLI/MapRotation.cs new file mode 100644 index 00000000..050a2e89 --- /dev/null +++ b/UnchainedLauncher.CLI/MapRotation.cs @@ -0,0 +1,71 @@ +using StructuredINI; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.RegularExpressions; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +public sealed class MapProfile { + public string? MapName { get; set; } +} + +public sealed record MapRotationEntry(string? Profile, MapProfile? Inline); + +internal sealed class MapRotationEntryYamlConverter : IYamlTypeConverter { + public bool Accepts(Type type) => type == typeof(MapRotationEntry); + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + if (parser.TryConsume(out var scalar)) { + if (string.IsNullOrWhiteSpace(scalar.Value) || scalar.Style == ScalarStyle.Plain && + (scalar.Value == "~" || scalar.Value.Equals("null", StringComparison.OrdinalIgnoreCase))) + throw new YamlException("map-rotation entries must be profile names or map-name objects."); + return new MapRotationEntry(scalar.Value, null); + } + if (!parser.Accept(out _)) + throw new YamlException("map-rotation entries must be profile names or map-name objects."); + return new MapRotationEntry(null, (MapProfile?)rootDeserializer(typeof(MapProfile))); + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + throw new NotSupportedException("Map rotation YAML is read-only."); +} + +internal sealed record MapRotation(IReadOnlyList Maps, int StartIndex) { + internal static void ValidateMap(MapProfile? profile, string location) { + if (profile?.MapName is not { } name || !Regex.IsMatch(name, @"\A/?[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*(?:\.[A-Za-z0-9_-]+)?\z")) + throw new ValidationException($"{location} requires a map-name containing a short map name or package path, without travel options."); + } + + internal static MapRotation? Resolve(List? entries, Dictionary? profiles, string? startingMap) { + if (entries is null) return null; + if (entries.Count == 0) throw new ValidationException("map-rotation must contain at least one map."); + var maps = new List(); + foreach (var entry in entries) { + var profile = entry?.Inline; + if (entry?.Profile is { } name) { + if (profiles is null || !profiles.TryGetValue(name, out profile)) + throw new ValidationException($"Unknown map-profile '{name}'; define it under root map-profiles or use an inline map-name object."); + } + ValidateMap(profile, "map-rotation entry"); + maps.Add(profile!.MapName!); + } + var index = startingMap is null ? 0 : maps.FindIndex(map => map.Equals(startingMap, StringComparison.OrdinalIgnoreCase)); + if (index < 0) throw new ValidationException("starting-map must match a map-name in map-rotation."); + return new MapRotation(maps.ToArray(), index); + } + + internal void Apply(string directory) { + var writer = new StructuredINIWriter(); + BufferInto(writer); + IniPatches.Write(directory, new(StringComparer.OrdinalIgnoreCase) { ["Game"] = writer }); + } + + internal void BufferInto(StructuredINIWriter writer) { + var values = IniPatches.Array("MapList", Maps) + .Append(KeyValuePair.Create("MapListIndex", StartIndex.ToString(CultureInfo.InvariantCulture))); + writer.MergeSection("/Script/TBL.TBLGameMode", values); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ModQuery.cs b/UnchainedLauncher.CLI/ModQuery.cs new file mode 100644 index 00000000..63e5de53 --- /dev/null +++ b/UnchainedLauncher.CLI/ModQuery.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public static class ModQuery { + public static async Task RunAsync(ModQueryOptions options, IModRegistry registry, TextWriter output, TextWriter error, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + if (options.Operation == ModQueryOperation.List) { + var catalog = await registry.GetAllMods().WaitAsync(cancellationToken); + await output.WriteLineAsync("MOD ID\tNAME\tLATEST VERSION"); + foreach (var mod in catalog.Mods.OrderBy(mod => Id(ModIdentifier.FromMod(mod)), StringComparer.Ordinal)) { + var latest = Latest(mod); + await output.WriteLineAsync($"{Id(ModIdentifier.FromMod(mod))}\t{Line(mod.LatestReleaseInfo.Name)}\t{Line(latest?.Tag ?? "none")}"); + } + foreach (var problem in catalog.Errors) await error.WriteLineAsync(problem.Message); + return catalog.HasErrors ? 1 : 0; + } + var result = await registry.GetMod(options.Mod!).ToEither().WaitAsync(cancellationToken); + if (result.IsLeft) { + await error.WriteLineAsync(result.Match(_ => "", problem => problem.Message)); + return 1; + } + var found = result.Match(mod => mod, _ => throw new InvalidOperationException()); + if (options.Operation == ModQueryOperation.Versions) { + await output.WriteLineAsync("VERSION\tRELEASE DATE\tCHANNEL"); + foreach (var release in found.Releases.OrderByDescending(release => release.Version).ThenByDescending(release => release.ReleaseDate)) + await output.WriteLineAsync($"{Line(release.Tag)}\t{release.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}\t{(release.Version?.IsRelease == true ? "stable" : "prerelease/non-semver")}"); + return 0; + } + var selected = options.Version is null ? Latest(found) : found.Releases.Find(release => release.Tag == options.Version); + if (selected is null) { + await error.WriteLineAsync($"Release not found: {Id(options.Mod!)}:{options.Version ?? "latest"}"); + return 1; + } + var size = await registry.GetPakSize(selected, cancellationToken); + await output.WriteLineAsync($"Mod ID: {Id(options.Mod!)}\nName: {Line(selected.Info.Name)}\nVersion: {Line(selected.Tag)}\nDescription: {Line(selected.Info.Description)}"); + await output.WriteLineAsync($"Authors: {string.Join(", ", selected.Info.Authors.Select(Line))}\nRepository: {Line(selected.Info.RepoUrl)}\nHomepage: {Line(selected.Info.HomePage ?? "none")}\nRelease date: {selected.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}"); + await output.WriteLineAsync($"Pak file: {Line(selected.PakFileName)}\nPak hash (SHA-512): {Line(selected.ReleaseHash)}\nSize: {(size is null ? "unknown (not available from registry/file)" : size.Value.ToString(CultureInfo.InvariantCulture) + " bytes")}"); + await output.WriteLineAsync($"Dependencies ({selected.Info.EffectiveModDependencies.Count}):"); + foreach (var dependency in selected.Info.EffectiveModDependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Plugin dependencies ({selected.Info.EffectivePluginDependencies.Count}):"); + foreach (var dependency in selected.Info.EffectivePluginDependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Maps ({selected.Manifest.Maps.Count}):"); + foreach (var map in selected.Manifest.Maps) + await output.WriteLineAsync($" {Line(map.MapName ?? map.Path)} | {Line(map.Path)} | mode: {Line(map.GameMode ?? map.GamemodeType ?? "unknown")}"); + await output.WriteLineAsync($"Blueprints ({selected.Manifest.Blueprints.Count}):"); + foreach (var blueprint in selected.Manifest.Blueprints) + await output.WriteLineAsync($" {Line(blueprint.ModName ?? blueprint.Path)} | {Line(blueprint.ClassPath ?? blueprint.Path)} | client-side: {blueprint.bClientside?.ToString() ?? "unknown"}"); + await output.WriteLineAsync($"Markers: {selected.Manifest.Markers.Count}\nReplacements: {selected.Manifest.Replacements.Count}\nOther assets: {selected.Manifest.Arbitrary.Count}\nRelease notes: {Line(selected.ReleaseNotesMarkdown ?? "none")}"); + return 0; + } + + private static Release? Latest(Mod mod) { + var stable = mod.LatestRelease; + return stable.IsSome ? stable.IfNone(() => throw new InvalidOperationException()) + : mod.Releases.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(); + } + internal static string Id(ModIdentifier id) => Line(id.Org + "/" + id.ModuleName); + internal static string Line(string value) => new(value.Select(character => char.IsControl(character) ? ' ' : character).ToArray()); +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ModQueryDefinition.cs b/UnchainedLauncher.CLI/ModQueryDefinition.cs new file mode 100644 index 00000000..02b2a404 --- /dev/null +++ b/UnchainedLauncher.CLI/ModQueryDefinition.cs @@ -0,0 +1,79 @@ +using System.CommandLine; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public enum ModQueryOperation { List, Get, Versions } +public enum RegistryPackageKind { Mod, Dll } + +public sealed class ModQueryOptions : CliCommandOptions { + public RegistryPackageKind Kind { get; init; } + public ModQueryOperation Operation { get; init; } + public ModIdentifier? Mod { get; init; } + public string? Version { get; init; } + public required RegistryOptions RegistryOptions { get; init; } +} + +internal sealed class ModQueryDefinition { + public Command Command { get; } + private readonly RegistryPackageKind _kind; + private readonly Command _list = new("list", "List mod IDs, names, and latest versions."); + private readonly Command _get = new("get", "Show release metadata, pak information, dependencies, and manifest contents."); + private readonly Command _versions = new("versions", "List available release tags for a mod."); + private readonly Argument _getId = new("mod-id") { Description = "organization/id" }; + private readonly Argument _versionsId = new("mod-id") { Description = "organization/id" }; + private readonly Option _version = new("--version") { Description = "Exact release tag; defaults to latest stable (or newest release if none is stable).", Arity = ArgumentArity.ExactlyOne }; + private readonly Option _registries; + + public ModQueryDefinition(RegistryPackageKind kind = RegistryPackageKind.Mod) { + _kind = kind; + Command = new Command(kind == RegistryPackageKind.Dll ? "dll-query" : "mod-query", "Query configured registries without installing or launching the game."); + if (kind == RegistryPackageKind.Dll) { + _list.Description = "List DLL IDs, names, and latest versions."; + _get.Description = "Show DLL release metadata, hashes, sizes, targets, and dependencies."; + _versions.Description = "List available DLL release tags."; + } + _registries = RegistryOptions.AddOption(Command, recursive: true); + _get.Arguments.Add(_getId); + _versions.Arguments.Add(_versionsId); + _get.Options.Add(_version); + _version.Validators.Add(result => { + if (result.IdentifierTokenCount != 1 || result.Tokens.Count != 1 || string.IsNullOrWhiteSpace(result.Tokens[0].Value)) + result.AddError("Specify one non-empty --version release tag."); + }); + Command.Subcommands.Add(_list); + Command.Subcommands.Add(_get); + Command.Subcommands.Add(_versions); + } + + public bool Contains(Command command) => command == Command || command == _list || command == _get || command == _versions; + + public ModQueryOptions ToOptions(ParseResult parsed, Option config) { + if (parsed.CommandResult.Command == Command) throw new ArgumentException($"Use {Command.Name} list, get , or versions ."); + var directory = Directory.GetCurrentDirectory(); + IEnumerable? specifications = parsed.GetValue(_registries); + if (parsed.GetResult(config) is { } selector) { + if (selector.IdentifierTokenCount != 1 || selector.Tokens.Count != 1 || string.IsNullOrWhiteSpace(selector.Tokens[0].Value)) + throw new ArgumentException("Specify exactly one non-empty --config-path."); + var path = Path.GetFullPath(parsed.GetValue(config)!); + specifications = YamlConfiguration.LoadDocument(path).ModSetup?.Registries; + directory = Path.GetDirectoryName(path)!; + } + var operation = parsed.CommandResult.Command == _list ? ModQueryOperation.List + : parsed.CommandResult.Command == _get ? ModQueryOperation.Get : ModQueryOperation.Versions; + var id = operation == ModQueryOperation.List ? null : parsed.GetValue(operation == ModQueryOperation.Get ? _getId : _versionsId); + ModIdentifier? identifier = null; + if (id is not null) { + // Reuse coordinate validation; no second grammar for organization/id. + var coordinate = SharedLaunchOptions.ParseCoordinates(id + ":query"); + identifier = new ModIdentifier(coordinate.Org, coordinate.ModuleName); + } + return new ModQueryOptions { + Kind = _kind, + Operation = operation, + Mod = identifier, + Version = parsed.GetValue(_version), + RegistryOptions = new RegistryOptions(specifications, directory) + }; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs new file mode 100644 index 00000000..c33b842d --- /dev/null +++ b/UnchainedLauncher.CLI/Program.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; + +namespace UnchainedLauncher.CLI; + +public static class Program { + public static async Task Main(string[] args) { + CliCommandOptions command; + try { + command = CommandLineOptions.Parse(args); + } + catch (ArgumentException ex) { + Console.Error.WriteLine(ex.Message); + Console.Error.WriteLine("Use '--help' for usage."); + return 2; + } + if (command.Help) { Console.Write(command.HelpText); return 0; } + var launches = command switch { + ServerGroupOptions group => group.Servers.Select(entry => (CliLaunchOptions)entry.Options).ToArray(), + CliLaunchOptions single => [single], + _ => Array.Empty() + }; + if (launches.Any(launch => launch.Shared.Dlls.Count != 0)) { + Console.Error.WriteLine("Resolving explicitly selected DLL releases from dll_db; native code will execute in the game."); + Console.Error.WriteLine("Tracking: https://github.com/Chiv2-Community/UnchainedLauncher/issues/149"); + } + if (launches.Any(launch => launch.Shared.UnsafeLoadAllDlls)) + Console.Error.WriteLine("DANGER: explicitly enabled injection of ALL DLLs recursively in Plugins; arbitrary native code will execute in the game."); + using var stop = new CancellationTokenSource(); + ConsoleCancelEventHandler handler = (_, e) => { e.Cancel = true; stop.Cancel(); }; + Console.CancelKeyPress += handler; + try { + if (command is ModQueryOptions query) { + var registry = query.RegistryOptions.CreateRegistry(); + return query.Kind == RegistryPackageKind.Dll + ? await DllQuery.RunAsync(query, registry, Console.Out, Console.Error, stop.Token) + : await ModQuery.RunAsync(query, registry, Console.Out, Console.Error, stop.Token); + } + if (command is ServerGroupOptions group) return await ServerGroupRunner.RunAsync(group, stop); + var options = (CliLaunchOptions)command; + using var instanceLease = options.InstancePaths.AcquireLease(); + using var process = await ServerLauncher.LaunchAsync(options, stop.Token); + return await SuperviseAsync(options, process, stop); + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) { return 0; } + catch (Exception ex) { + Console.Error.WriteLine(ex.Message); + return 1; + } + finally { Console.CancelKeyPress -= handler; } + } + + internal static async Task SuperviseAsync(CliLaunchOptions options, Process process, CancellationTokenSource stop) { + if (options is ClientOptions) { + // Match the GUI's client lifecycle: launch the game, then leave it + // running independently. Only servers are owned by this console. + Console.WriteLine($"Client launched (PID {process.Id})."); + return 0; + } + if (options is not ServerOptions server) throw new ArgumentException("Expected a launch request."); + try { + using var dashboard = new ServerDashboard(!server.NoTui, server.ToServerLaunchOptions()); + var monitor = new ServerMonitor(process, ServerLauncher.CreateQuery(server)); + await monitor.RunAsync(dashboard, stop); + return stop.IsCancellationRequested ? 0 : process.ExitCode; + } + finally { + // The CLI owns its server: cancellation and monitor failures must not + // leave an unobserved server running. Kill is Core's existing stop behavior. + if (!process.HasExited) { + Console.Error.WriteLine("Stopping server..."); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md new file mode 100644 index 00000000..146a1c78 --- /dev/null +++ b/UnchainedLauncher.CLI/README.md @@ -0,0 +1,614 @@ +# Launcher CLI + +This project launches an Unchained Chivalry 2 **client or server**, without the WPF GUI. +On Windows, the shared game launcher creates the game suspended, injects DLLs, +then resumes its main thread. Failed injection terminates the suspended game. +CLI servers also pass TOS-acceptance and first-load-completed INI overrides to +avoid interactive first-run screens. The CLI also initializes the game's saved profile. +Profile-local paks use the game's native `Saved_/Paks` search directory. +It uses Core for mod installation, plugin updates, signature preparation, +process launching, DLL injection, and A2S queries. + +## Build and run + +Install the .NET 10 SDK. From the repository root: + +```powershell +dotnet build UnchainedLauncher.CLI -c Release +dotnet run --project UnchainedLauncher.CLI -c Release -- server --help +``` + +Launch on **Windows** with an existing Chivalry 2 installation: + +```powershell +dotnet run --project UnchainedLauncher.CLI -c Release -- server ` + --chivalry2-path "C:\Games\Chivalry 2" ` + --platform Steam ` + --saved-dir-suffix MyServer ` + --name "My Unchained Server" ` + --starting-map FFA_Courtyard ` + --dll Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 ` + --mod Chiv2-Community/Unchained-Mods:v0.1.0 ` + --mod Chiv2-Community/Chiv2Turbo:v0.0.4 ` + --server-mod /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C +``` + +The path is the installation root containing `TBL`, not the Win64 directory. +The example pins registered releases. +The server is headless by default (`-nullrhi -unattended -nosound`); use +`--headless false --no-desync-patch` to render it (the desync patch only supports +headless servers). The plugin is installed +or updated from the latest stable UnchainedPlugin release by default. Use +`--no-plugin-update` to disable DLL downloads, not version selection; all required +registered DLL versions must already be installed or cached. To retain a desired +registered plugin version, pin its exact tag with +`--dll Chiv2-Community/UnchainedPlugin:`. +`--allow-unstable-plugin` permits automatic prerelease selection. + +For deployment without a .NET runtime: + +```powershell +dotnet publish UnchainedLauncher.CLI -c Release -r win-x64 --self-contained true -o out-cli +.\out-cli\UnchainedLauncher.CLI.exe server --help +``` + +Help, parsing, and the unit tests also work on Linux. Actual game launching and +DLL injection require Windows; Wine/Proton launching is not implemented. + +## Mod registry queries + +Query metadata without installing mods, changing the game profile, or launching +the game. These commands work on Linux as well as Windows and require no game +installation or platform selection: + +```text +UnchainedLauncher.CLI mod-query list +UnchainedLauncher.CLI mod-query get Chiv2-Community/Unchained-Mods +UnchainedLauncher.CLI mod-query get organization/mod-id --version v1.2.3 +UnchainedLauncher.CLI mod-query versions organization/mod-id +UnchainedLauncher.CLI mod-query list --add-registry local:./registry +UnchainedLauncher.CLI mod-query get organization/mod-id --config-path ./registry-query.yaml +``` + +The official registry is always included. Repeat `--add-registry` to include +GitHub, local, or Core JSON-config registries, using the same formats as launch +commands. For queries, `--config-path` reads root `mod-setup.registries` from a +query or client YAML file instead of the `--add-registry` arguments. It does not +select or merge the per-server registries in server YAML. YAML-relative paths resolve +relative to that file. Only registry configuration is used: installation, +platform, client/server settings, mod selections, and plugin policies are not +required or applied. Query operands and `--version` remain command-line inputs. + +`list` displays mod ID (`organization/id`), name, and latest version. `get` +defaults to Core's latest stable semantic version; if none is stable it shows +the newest release by date. `--version` selects an exact release tag, including +prereleases. `versions` lists all tags, release dates, and stable/prerelease status. + +Details include release-specific description/authors, repository/homepage, +release date/notes, pak filename, SHA-512 hash, size in bytes, declared dependency +ranges, map count/names/paths/modes, blueprints, and other manifest asset counts. +Local size is read from the filesystem; GitHub size uses an HTTP HEAD request, +never a pak download. An inaccessible pak or missing length is reported as +`unknown`, not zero. Declared dependencies are displayed, not installed or resolved. + +Exit status is 0 for success, 2 for invalid arguments/configuration, and 1 for +registry/runtime errors or a missing mod/version. A failed registry during +`list` leaves the available rows visible but reports errors and exits 1 rather +than presenting a partial catalog as complete. Game arguments after `--` are +not accepted by `mod-query`. Use `mod-query --help` for details. + +## DLL registry queries + +```text +UnchainedLauncher.CLI dll-query list +UnchainedLauncher.CLI dll-query get Chiv2-Community/UnchainedPlugin +UnchainedLauncher.CLI dll-query get Chiv2-Community/UnchainedPlugin --version v1.0.0-RC2 +UnchainedLauncher.CLI dll-query versions Chiv2-Community/UnchainedPlugin +UnchainedLauncher.CLI dll-query list --config-path ./server.yaml +``` + +DLL queries share the mod-query command definitions, configured registry object, +and `--add-registry`/`--config-path` behavior. They use the existing +[zero-infra plugin schema](https://github.com/Chiv2-Community/zero-infra-mod-registry/blob/prepare-v1/schemas/plugin.schema.json) +and live C2ModRegistry `dll_db`: `plugin_list_index.txt` and +`packages//.json`. Local registries use +`/dll_db/packages/...`. Pak-only registries without a DLL +database contribute no DLL packages. + +Details show release-specific name/description/authors, execution target +(`client`, `server`, or `shared`), filename, **SHA-256** hash, recorded size, +download URL, plugin dependencies separately from pak mod dependencies, and +release notes. Versions include prereleases; defaults choose latest stable, +falling back to the newest dated release when no stable exists. Queries do not +download, install, or execute DLLs. Recorded hashes/sizes are registry metadata, +not a claim that an installed file has been verified. + +## YAML configuration + +CLI options and command-specific help are defined with System.CommandLine. +Use `server --help` or `client --help` to list the options for that command. +CLI input and typed YAML configurations share the same models and validation. +Client and server launch requests compose `SharedLaunchOptions` for installation, +platform, profile, registry/mod selection, plugin-update policy, and common game +arguments. Server-only settings are carried only by `ServerOptions`; there is no +client/server mode flag on a launch request. Platforms use Core's `InstallationType`; +`EGS` remains an alias for `EpicGamesStore` in both CLI and YAML input. + +Use a configuration file instead of individual arguments: + +```powershell +.\out-cli\UnchainedLauncher.CLI.exe server --config-path "C:\Configs\server.yaml" +.\out-cli\UnchainedLauncher.CLI.exe client --config-path "C:\Configs\client.yaml" +# From the repository root: +dotnet run --project UnchainedLauncher.CLI -- server --config-path "C:\Configs\server.yaml" +``` + +`--config-path` (or `--config-path=...`) replaces **all other launcher and game +arguments except the required `client`/`server` subcommand**. There are no CLI +overrides or merging. Put all intended options in the file. A `--config-path` +token after the game-argument separator `--` is just a game argument, not a +configuration selector. Relative installation and local/config registry paths +in YAML are resolved relative to the YAML file, not the caller's working directory. + +Launcher response-file expansion (`@file`) is deliberately disabled: `@`-prefixed +option values are literal strings, and bare `@file` launcher arguments are rejected +like other unexpected positional arguments. This prevents file reads from ignored +CLI values when YAML is authoritative and keeps one library parse per invocation. +Put reusable launcher settings in YAML instead. Arguments after `--`, including +`@file`, are forwarded unchanged to the game (or ignored when YAML is selected). + +`chiv2-path` and `platform` (`Steam` or `EGS`) are required. See the complete +[server example](examples/server.yaml) and [client example](examples/client.yaml). + +```yaml +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam +server: + saved-dir-suffix: MyServer + mod-setup: + registries: [] # Official registry remains enabled, even with an empty list. + mods: [] # organization/mod-id:exact-release-tag + dlls: [] # Optional dll_db coordinates: organization/id:version. + name: My Server + description: My server description + game-port: 7777 + ping-port: 3075 + query-port: 7071 + rcon-port: 9001 + starting-map: FFA_Courtyard + mod-actors: [] # Explicit blueprint class paths to activate. + desync-patch: true + use-backend-banlist: true +``` + +Separate mode-specific schemas: +- The **subcommand**, not the document's contents, always determines mode. +- `server --config-path ...` reads the `server` mapping and completely ignores `client`. +- `client --config-path ...` reads the `client` mapping and completely ignores `server`, + including actors, Discord, and server-only arguments; ignored blocks are not schema-validated. +- A missing active block uses that command's defaults. `client` is a mapping, not a boolean mode switch. +- Client launches omit `-server`, headless flags, and server-specific options, + do not create a server dashboard, and return after launching without stopping the game. + +Common root options are `server-browser-backend`, `update-plugin` (default true), +`allow-unstable-plugin` (default false), and `game-args` (list of strings). +The root `no-tui` option (default false) is server-only. +`server.game-args` or `client.game-args` is appended to the +common list only for the matching command. The client schema currently supports +`game-args`; client installation, platform, plugin, mod, and saved-profile options remain at the root. +For servers, `mod-setup` and `saved-dir-suffix` belong inside `server` or each +`servers` entry, never at the root. A single unnamed server defaults to `UnchainedCLI`. + +The server section supports name, description/MOTD, password, `starting-map`, local IP, +all four ports, `headless`, `register`, `mod-actors`, `desync-patch`, +`use-backend-banlist`, and `censor-mode` (`none`, `standard`, `sex`, `zealous`). +Desync patch and backend banlist **default to true in server mode**; set their +YAML values to false, or pass `--no-desync-patch` / `--no-backend-banlist`, to disable. +Rendered servers require `headless: false` **and** `desync-patch: false` in YAML, +or `--headless false --no-desync-patch` on the command line. Incompatible settings +are rejected before launch preparation; neither default is silently changed. +Local IPv4 is automatically detected from active non-loopback/non-tunnel interfaces, +preferring one with a default gateway; it falls back to another usable IPv4 or loopback. +Set `local-ip` only to override this choice (for example, with a VPN or multiple adapters). +Time/score/ticket limits, player bot count, and warmup are **INI settings**, not +dedicated launcher flags; configure them in the selected saved profile or `server.ini`. +Use `starting-map` / `--starting-map`; the former `map` / `--map` names are rejected. +Before a server launch, the CLI initializes the selected profile's `Game.ini` with +`bSavedHasAgreedToTOS=True` and `FirstLoadCompleted=True`, matching the GUI's startup +defaults so first-run screens do not block unattended startup. Other existing INI +settings are preserved. Client launches do not initialize or rewrite INI files. +Chivalry removes hyphens from the suffix in its `Saved_` directory; +the CLI initializes that directory. Plugin log paths retain the original suffix. + +### Map rotation and profiles + +```yaml +map-profiles: + courtyard: + map-name: FFA_Courtyard + +servers: + first: + map-rotation: + - courtyard + - map-name: FFA_Wardenglade + second: + game-port: 7778 + ping-port: 3076 + query-port: 7072 + rcon-port: 9002 + map-rotation: + - courtyard + - map-name: FFA_Bazaar +``` + +- Put `map-rotation` inside `server` or each `servers` entry. +- Entries are inline `{map-name: ...}` objects or exact, case-sensitive keys from root `map-profiles`. +- A bare string is a profile reference, not a map name. +- Order and repeated entries are preserved. +- The first entry is the default starting map. +- Set `starting-map` to a rotation map-name to start at its first occurrence, matching case-insensitively. +- Map names accept short names or package paths, without travel options. +- An explicit rotation must contain at least one entry. +- Rotation setup replaces only `MapList` and `MapListIndex` in the server's saved `Game.ini`. +- Omitting `map-rotation` preserves the profile's existing rotation and the normal `starting-map` default. +- `MapList` is forbidden in `ini`, including typed and `raw` forms. +- `MapListIndex` cannot be set through `ini` when a rotation is configured. +- Profile and inline map objects currently accept only `map-name`. +- Per-map INI and actors remain deferred to [#154](https://github.com/Chiv2-Community/UnchainedLauncher/issues/154) and [#155](https://github.com/Chiv2-Community/UnchainedLauncher/issues/155). +- Server-level `mod-actors` still apply to every map. + +### INI overrides + +Nest `ini` under `server` or a named `servers` entry. Its shape is +`file category -> section -> field -> value`: + +```yaml +server: + ini: + Game: + /Script/TBL.TBLGameMode: + BotBackfillEnabled: true + ClassLimits: + - Class: Poleman + ClassLimitPercent: 10 + - Class: Archer + ClassLimitPercent: 0 + SomeUnknownOption: + type: String + value: example + SomeOtherUnknownOption: + type: raw + value: SomeStructuredThing(foo, bar, 10, baz, false) +``` + +- Known fields infer their type from Core's INI models. They also accept + `{value: ...}` or `{type: Boolean, value: true}`. An explicit known-field type + must match its definition, unless it is `raw`. +- Unknown fields require both `type` and `value`; a bare value fails with instructions. +- Types are case-insensitive: `String`, `Boolean` (`Bool`), `Int32` (`Int`), + `Single` (`Float`), `Double`, `Decimal`, `CharacterClass`, `ClassLimit`, + `AutoBalance`, and `CallbackList`. Append `[]` for an array, for example + `{type: 'String[]', value: [one, two]}`. Quote array type names in YAML flow mappings. +- Records accept named members or positional lists in constructor order: + `ClassLimit` uses `[Class, ClassLimitPercent]`; `AutoBalance` uses + `[MinNumPlayers, MaxNumPlayers, AllowedNumPlayersDifference]`. + `CallbackList` accepts a list of strings. Numbers use invariant decimal notation; + enum values must be named members. Values are not rescaled (10 stays 10). +- Typed values patch only selected keys in the server's saved-profile INI files. + Arrays replace that field's existing entries; `[]` explicitly clears it. + Other keys and sections are preserved. These changes persist after the server exits; + omitting a field on a later launch does not undo it. +- Use `map-rotation`, not an INI `MapList` override, to configure maps. +- `raw` requires a string and bypasses type parsing and validation. Its exact value + is escaped for transport in one `-ini:` argument, not written to the profile. + It cannot override an array: Unreal's command-line INI overrides do not support arrays. +- File categories have no path or `.ini` extension (`Game`, `Engine`, etc.). + All overrides are validated before preparation. Explicit INI `ServerName` and + startup-screen fields take precedence over the launcher's generated defaults. + Forwarded `game-args` remain explicit user arguments; avoid conflicting INI overrides there. + +Discord configuration is nested under the server: + +```yaml +server: + discord: + bot-token: 'your-bot-token' + general-channel-id: '123456789012345678' + # Optional: admin-channel-id, dashboard-channel-id, event-log-channel-id, + # admin-role-id, mention-admins (default true). +``` + +Discord requires a token and at least one channel. Keep tokens/passwords private +and restrict access to configuration files; do not commit real credentials. +YAML syntax errors report a location without quoting the configuration contents. +Unknown keys, duplicate keys, invalid types, ports, modes, or coordinates fail +before preparing or launching the game. Lists accept any number of entries. + +### Multiple servers + +Use a `servers` dictionary instead of the single `server` block: + +```yaml +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam +servers: + courtyard: + name: Courtyard FFA + starting-map: FFA_Courtyard + game-port: 7777 + ping-port: 3075 + query-port: 7071 + rcon-port: 9001 + arena: + name: Arena FFA + starting-map: FFA_Courtyard + game-port: 7778 + ping-port: 3076 + query-port: 7072 + rcon-port: 9002 +``` + +Run `UnchainedLauncher.CLI.exe server --config-path servers.yaml`. +See [the multi-server example](examples/servers.yaml). The existing `server` schema +and individual CLI launch arguments still launch one server. + +- Keys are tab IDs: unique, case-insensitive letters, digits, underscores, and hyphens. +- Each entry accepts the same fields as `server`, including its own `mod-setup`, + `ini`, actors, arguments, password, and Discord settings. Installation, platform, + plugin-update policy, and common arguments remain shared at the root. + Registries and pak/DLL selections are independent; verified bytes share the cache. +- Profiles default to the server key (`courtyard`, `arena`). Set `saved-dir-suffix` inside an + entry to override it. Profile names must be distinct, ignoring case and hyphens. +- Active paks live under `%LOCALAPPDATA%/Chivalry 2/Saved_/Paks`; + DLLs remain under `%LOCALAPPDATA%/Chivalry 2/Saved_/unchained/plugins`. + Hyphens are removed from the saved suffix, matching the game. This also applies to + clients; the default client suffix is `unchained`. A per-profile lock prevents concurrent + CLI processes from using the same profile. Plugin injection never scans another + instance's directory, including with the unsafe flag. +- The game discovers profile paks without an additional plugin argument. + Managed filenames use `__P.pak`, using Unreal's native patch priority + instead of alphabetical prefixes. Orders start at zero in dependency order; + higher numbers override lower numbers, so dependents override their dependencies + and all managed mods override vanilla assets. Collision counters stay before + the order suffix, for example `SomeMod (1)_2_P.pak`. + Signatures use the installation's base + signature as a read-only template; no base pak is copied or modified. +- Relaunch with the desired `--mod` selections to install them into `Paks`. + Old files under `unchained/paks` are not migrated or deleted automatically. +- Profile-local discovery does not suppress game-wide paks + already present in `TBL/Content/Paks`. Existing global mods may still affect all + instances. The launcher does not move or delete them during instance setup. +- Shared caches live under `%LOCALAPPDATA%/Chivalry 2/unchained/cache/{paks,dlls}`. + Pak objects are keyed by SHA-512 and downloaded once under a per-object lock. + Profiles use hard links when supported, or copies from cache across volumes or + unsupported filesystems. Cache objects and linked paks must not be edited in place. + Signatures stay profile-local. Windows may prevent removal or reordering of a + linked pak while another process holds that file open; stop affected servers first. +- Assign all four ports to each server. Every port must be distinct across the + entire group, even when local IPs differ. Defaults are not automatically offset. +- All entries are validated before launch. Startup is sequential because Core + preparation changes the installation and working directory. A startup failure + stops previously launched servers; it does not leave a partial group running. +- After startup, one server exiting does not stop the others. Its tab retains the + exit code and last metrics. Once all exit, the CLI returns the first nonzero exit + code in dictionary order, or zero if all succeeded. Q/Ctrl+C stops the entire group. + +Do not mix `server` and `servers` in one document. The client command and registry +queries ignore the inactive `servers` block. + +The `client` command also accepts individual common arguments, for example: + +```text +UnchainedLauncher.CLI.exe client --chivalry2-path "C:\Games\Chivalry 2" --platform Steam +``` + +## Registries and mods + +The official `github:Chiv2-Community/C2ModRegistry` registry is always included. +Repeat `--add-registry` to add any combination of Core's supported registry types: + +```text +--add-registry github:owner/repository +--add-registry "local:C:\ModRegistry" +--add-registry "config:C:\Configs\registries.json" +``` + +- `github:` reads the repository's `main/package_db` layout, just like the GUI. +- `local:` points to an existing Core local registry, not a directory of arbitrary paks. +- `config:` reads Core's tagged registry configuration, supporting nested aggregates. + Relative local paths inside it are resolved against the JSON file's directory. +- Relative command-line paths are resolved against the directory where you run the CLI. + +Example aggregate configuration: + +```json +{ + "Kind": "AggregateModRegistry", + "Registries": [ + { "Kind": "GithubModRegistry", "Org": "owner", "RepoName": "repository" }, + { "Kind": "LocalModRegistry", "PakReleaseDir": "../LocalModRegistry" } + ] +} +``` + +Request pak releases using their repository identity and **exact release tag**: + +```text +--mod organization/mod-id:v1.2.3 +--mod organization/another-mod:v2.0.0 +``` + +Display names are not IDs. Core resolves dependencies and includes Unchained-Mods +automatically. Its current dependency policy selects latest stable releases rather +than enforcing dependency version ranges; the CLI rejects missing releases, +missing stable dependencies, and conflicting resolved versions rather than +launching an incomplete set. Registry failures stop launch. +If a pinned older release has different dependencies from the latest release, +Core may resolve the wrong dependency inventory; the CLI detects missing required +dependencies and refuses that launch rather than silently omitting them. + +Installing a pak does **not** automatically activate all of its server actors. +As in the GUI, choose the actors you want to activate, using their blueprint class +paths from release metadata: + +```text +--server-mod /Game/Mods/Example/BP_Example.BP_Example_C +``` + +Core records physical managed-pak ownership in each active pak directory's +`.unchained_managed_paks.json`, updated after preparation, including partial failures. +That inventory is shared by hosts using the same profile; it does not authorize +changes to another profile or to legacy installation-wide paks. +The CLI saves its metadata in the profile's `unchained/cli_mod_manager.json`, but +never overwrites GUI configuration or reuses the GUI's enabled-mod selection. +Each launch reconciles the installed managed set with that host's selected releases; +genuinely unmanaged user paks are preserved. +Core's installation pipeline changes the installed managed pak set and signatures. +The GUI installs paks in each launch profile's `Saved_/Paks` directory, +using a shared GUI mod selection and shared `Saved_unchained/unchained/plugins` DLLs. +CLI mod and DLL selections remain request-specific. CLI profiles +need separate saved suffixes/ports; CLI startup +preparation remains sequential because some Core operations change CWD and metadata. + +## DLL compatibility behavior + +GUI and CLI share DLL registry validation, managed storage, caching, and injection. +The CLI selects UnchainedPlugin from `dll_db` for each launch; GUI selections stay +in the Plugin Manager's saved profile. +`--dll organization/id:version` (or each server's YAML `mod-setup.dlls`) additionally selects +registry releases in CLI launches. Plugin dependencies are resolved before their +dependents; target mismatches, missing releases, cycles, and conflicting versions +fail preparation. Pak dependencies enter the shared mod manager. + +Every selected DLL must match the registry's SHA-256 and size. Downloads are +verified before installation. Active DLLs use +`plugins/Managed///` under the selected +profile's `unchained` directory; inactive registered versions use the shared +`unchained/cache/dlls//`. Switching versions restores +validated cached files without downloading again. +Active DLLs use hard links to validated cached bytes where supported, with an atomic +copy fallback across volumes or on unsupported filesystems. Concurrent preparations +serialize downloads per cache object. Never edit cached or linked DLLs in place. +If Windows blocks removal of a DLL linked to a running peer, stop that peer before +changing versions; preparation fails rather than modifying shared bytes. +Registered files in historical `Managed` hash directories or `.dll-managed` +directories migrate after validation. +Loose DLLs, unknown filenames, and unregistered versions stay untouched and are +not loaded in safe mode. CLI selections never overwrite the GUI plugin profile. +`plugins/dll_manager.json` records the last successful CLI selection. +Safe injection uses only that launch's verified allowlist, not the directory or +the saved inventory. The unsafe load-all option retains other DLLs for injection. +`--no-plugin-update` forbids downloads but permits restoring cached versions. +It does not retain the installed version; pin the desired registered tag with +`--dll Chiv2-Community/UnchainedPlugin:` and install or cache every required DLL. +Only explicitly pinned prereleases (or `--allow-unstable-plugin` for automatic +selection) are eligible; automatic selection otherwise uses the latest stable. + +Other DLLs in the selected profile's `unchained/plugins` directory are not enumerated +for injection unless this exact command-line flag is explicitly supplied. Legacy +installation-wide plugin directories are left untouched and are never scanned: + +```text +--unsafe-very-dangerous-load-all-dlls +``` + +This is a dangerous compatibility escape hatch: it recursively injects **all** +DLLs in Plugins, executing arbitrary native code in the game. For the GUI, +pass it when starting `UnchainedLauncher.GUI.exe`; for the CLI, pass it to a +`client` or `server` command. It is hidden from generated help, has no GUI +checkbox, is never saved in settings, and is not accepted as a YAML property. +It must be explicitly present before `--`, even with `--config-path`; unlike +ordinary YAML-overridden launch settings, this process-local permission is +honored only from the actual command line. After `--` it is only a game argument +and grants no permission. It is rejected for query commands. + +The unsafe escape hatch also includes retained managed releases; it can load +multiple versions and bypass normal selection. Only opt in if you trust every +DLL in this directory. Registry integrity checks establish file identity, not +that native code is safe. + +## Server options + +Run `server --help` for the full option list. Additional settings include: + +| Option | Default / behavior | +| --- | --- | +| `--saved-dir-suffix` | client: `unchained`; server: `UnchainedCLI`; letters, digits, `_`, and `-` only | +| `--name`, `--description`, `--password` | Name, description/MOTD, optional password | +| `--starting-map` | `FFA_Courtyard` | +| `--game-port` | `7777` | +| `--ping-port` | `3075` | +| `--query-port` | `7071` (A2S) | +| `--rcon-port` | `9001` | +| `--local-ip` | Optional override of auto-detected local IPv4; also used for A2S monitoring | +| `--register` | Opt in to server-browser registration; off by default | +| `--server-browser-backend` | `https://servers.polehammer.net` | +| `--no-tui` | Periodic plain-text status instead of the dashboard | +| `--headless true/false` | `true`; rendering requires `--no-desync-patch` | +| `--no-desync-patch`, `--no-backend-banlist` | Disable the default-enabled server features | +| `--censor-mode` | `none`; also `standard`, `sex`, `zealous` | +| `--discord-bot-token` | Requires at least one Discord channel | +| `--discord-admin-channel-id`, `--discord-general-channel-id` | Optional channel IDs | +| `--discord-dashboard-channel-id`, `--discord-event-log-channel-id` | Optional channel IDs | +| `--discord-admin-role-id`, `--discord-mention-admins true/false` | Optional role; mention defaults true | + +Ports must be distinct and between 1 and 65535. Configure firewall/port forwarding +for your deployment; this CLI does not change firewall rules. Existing INI files +in the selected saved profile supply settings that are not exposed as flags. + +Pass additional game/plugin arguments after `--`; they are forwarded with escaping: + +```text +UnchainedLauncher.CLI.exe server --chivalry2-path "C:\Games\Chivalry 2" --platform Steam -- -log +``` + +`--platform Steam` creates `steam_appid.txt` with Chivalry 2's app ID if it is missing; +Steam must be available for authentication. `--platform EGS` does not obtain Epic +credentials. Supply the current Epic launch/authentication arguments after `--` +from your existing EGS launch flow. Selecting EGS alone does not authenticate you. +Avoid exposing authentication arguments and passwords in shared scripts or process listings. + +## Live dashboard and shutdown + +While the server is running, the dashboard displays: + +- Server name, PID, process uptime, and restart count (zero; no automatic restart). +- Game, query, beacon, and RCON ports, local IP, and configured mod actors. +- CPU use as a percentage of total host logical CPU capacity (0–100%). +- Private process memory and observed peak memory in MiB, plus handle count. +- A2S player/max-player counts, current map, game type, and query availability. +- Bounded rolling CPU/memory/player sparklines and an uptime-state strip + (up to 12 hours, sampled every ten seconds, scaled to the terminal width). +- A bounded timeline of launch, map/player changes, and query outages/recovery. + +Metrics refresh approximately once per second. During startup or query failure, +player count and map are **unknown**, not zero; `?` marks missing chart samples. +The timeline represents observed changes, not per-player join/leave identities or +the full game log. The uptime strip shows `S` until the first successful query, +then `U` while the process runs, including query outages, matching the GUI. +Network throughput and RCON controls are not included yet. + +Press **Q** in the TUI or **Ctrl+C** to stop. **Page Up**/**Page Down** scroll wrapped content +and **Home** returns to the top; narrow terminals do not discard long mod lists. +With multiple servers, **Left**/**Right** select tabs, wrapping at either end. +Click a visible tab with the primary mouse button in terminals supporting SGR mouse +reports (including xterm-compatible terminals and Windows Terminal). Keyboard +navigation remains available when mouse reporting is unsupported. Mouse capture +and Windows console modes are restored on exit. +Redirected input/output and dumb terminals +automatically use plain-text status (every ten seconds). The CLI owns its server: +stopping it terminates the game process, matching Core's current stop behavior; +there is no detach mode or automatic restart. Natural exits return the server's +exit code, argument errors return `2`, and launch/monitor failures return `1`. + +## Tests + +```text +dotnet test UnchainedLauncher.CLI.Tests -c Debug +dotnet test UnchainedLauncher.CLI.Tests -c Release +``` + +Tests cover parsing, Core option mapping, registry configuration, dependency checks, +bounded history, unavailable telemetry, and monitor cancellation without a game installation. +Windows CI builds, tests, and publishes the CLI separately from the GUI. diff --git a/UnchainedLauncher.CLI/RegistryOptions.cs b/UnchainedLauncher.CLI/RegistryOptions.cs new file mode 100644 index 00000000..1bc776a1 --- /dev/null +++ b/UnchainedLauncher.CLI/RegistryOptions.cs @@ -0,0 +1,26 @@ +using System.CommandLine; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public sealed class RegistryOptions { + public IReadOnlyList Registries { get; } + + public RegistryOptions(IEnumerable? specifications, string directory) => + Registries = SharedLaunchOptions.Entries(specifications).Select(value => RegistrySpec.Parse(value, directory)).ToArray(); + + public AggregateModRegistry CreateRegistry() => new(new IModRegistry[] { + new GithubModRegistry("Chiv2-Community", "C2ModRegistry") + }.Concat(Registries.Select(spec => spec.Create()))); + + internal static Option AddOption(Command command, bool recursive = false) { + var option = new Option("--add-registry") { + Description = "Additional github:owner/repo, local:directory, or config:file registry; official registry is always included.", + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = false, + Recursive = recursive + }; + command.Options.Add(option); + return option; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs new file mode 100644 index 00000000..c4d22f72 --- /dev/null +++ b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs @@ -0,0 +1,27 @@ +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +// A read-only projection of a launch YAML file. Querying requires only registry +// specifications, not a valid installation, credentials, or launch settings. +public sealed class RegistryQueryConfiguration { + public RegistryQueryModSetup? ModSetup { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Chiv2Path { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Platform { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? SavedDirSuffix { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? ServerBrowserBackend { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? UpdatePlugin { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? AllowUnstablePlugin { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? NoTui { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? GameArgs { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Server { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Servers { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? MapProfiles { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Client { get; set; } +} + +public sealed class RegistryQueryModSetup { + public List? Registries { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Mods { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Dlls { get; set; } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/RegistrySpec.cs b/UnchainedLauncher.CLI/RegistrySpec.cs new file mode 100644 index 00000000..21c89292 --- /dev/null +++ b/UnchainedLauncher.CLI/RegistrySpec.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public sealed record RegistrySpec(string Kind, string Location) { + public static RegistrySpec Parse(string value, string? baseDirectory = null) { + var parts = value.Split(':', 2); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[1])) + throw new ArgumentException("Registry must be github:owner/repo, local:, or config:."); + var kind = parts[0].ToLowerInvariant(); + if (kind == "github") { + if (!Regex.IsMatch(parts[1], @"\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z")) + throw new ArgumentException("GitHub registry must be github:owner/repo."); + return new RegistrySpec(kind, parts[1]); + } + if (kind is "local" or "config") return new RegistrySpec(kind, + baseDirectory is null ? Path.GetFullPath(parts[1]) : Path.GetFullPath(parts[1], baseDirectory)); + throw new ArgumentException($"Unsupported registry kind: {kind}"); + } + + public IModRegistry Create() => Kind switch { + "github" => new GithubModRegistry(Location.Split('/')[0], Location.Split('/')[1]), + "local" => new LocalModRegistry(RequireLocalDirectory(Location)), + "config" => LoadConfiguration(), + _ => throw new ArgumentException($"Unsupported registry kind: {Kind}") + }; + + private IModRegistry LoadConfiguration() { + // Core resolves relative local paths against the current directory. Resolve them + // against the configuration's directory instead, without changing process CWD. + var result = Core.Utilities.JsonHelpers.Deserialize(File.ReadAllText(Location)); + if (!result.Success) throw new ArgumentException($"Invalid registry configuration: {Location}", result.Exception); + return ModRegistryCodec.ToClassType(ResolvePaths(result.Result!, Path.GetDirectoryName(Location)!)); + } + + private static ModRegistryMetadata ResolvePaths(ModRegistryMetadata metadata, string directory) => metadata switch { + LocalModRegistryMetadata local => local with { PakReleaseDir = RequireLocalDirectory(Path.GetFullPath(local.PakReleaseDir, directory)) }, + AggregateModRegistryMetadata aggregate => aggregate with { + Registries = aggregate.Registries.Select(r => ResolvePaths(r, directory)).ToArray() + }, + _ => metadata + }; + + private static string RequireLocalDirectory(string path) => Directory.Exists(path) ? path + : throw new DirectoryNotFoundException($"Local registry directory does not exist: {path}"); +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerDashboard.cs b/UnchainedLauncher.CLI/ServerDashboard.cs new file mode 100644 index 00000000..15c6166e --- /dev/null +++ b/UnchainedLauncher.CLI/ServerDashboard.cs @@ -0,0 +1,157 @@ +using System.Globalization; +using UnchainedLauncher.Core.Services.Processes.Chivalry; + +namespace UnchainedLauncher.CLI; + +public sealed class ServerDashboard : IDisposable { + private bool _interactive; + private bool? _previousCursorVisible; + private readonly Dictionary _lastPlainOutput = []; + private readonly ServerLaunchOptions? _launchOptions; + private int _scrollOffset; + private readonly ServerTabs? _tabs; + private readonly TerminalMouseParser _mouseParser = new(); + private TerminalMouse? _mouse; + public bool IsInteractive => _interactive; + public int SelectedTab => _tabs?.Selected ?? 0; + + public ServerDashboard(bool useTui, ServerLaunchOptions? launchOptions = null, IReadOnlyList? tabNames = null) { + _launchOptions = launchOptions; + if (tabNames is { Count: > 0 }) _tabs = new ServerTabs(tabNames); + _interactive = useTui && !Console.IsOutputRedirected && !Console.IsInputRedirected + && Environment.GetEnvironmentVariable("TERM") != "dumb"; + if (_interactive) { + try { + if (OperatingSystem.IsWindows()) _previousCursorVisible = Console.CursorVisible; + Console.CursorVisible = false; + Console.Clear(); + if (_tabs is not null) _mouse = new TerminalMouse(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException) { _interactive = false; } + } + } + + public bool StopRequested() { + if (!_interactive) return false; + try { + while (Console.KeyAvailable) { + var key = Console.ReadKey(intercept: true); + if (_mouseParser.Feed(key.KeyChar, out var click)) { + if (click is { } position && _tabs?.Click(position.Column, position.Row) == true) _scrollOffset = 0; + continue; + } + switch (key.Key) { + case ConsoleKey.Q: return true; + case ConsoleKey.PageDown: _scrollOffset += 10; break; + case ConsoleKey.PageUp: _scrollOffset = Math.Max(0, _scrollOffset - 10); break; + case ConsoleKey.Home: _scrollOffset = 0; break; + case ConsoleKey.LeftArrow: _tabs?.Move(-1); _scrollOffset = 0; break; + case ConsoleKey.RightArrow: _tabs?.Move(1); _scrollOffset = 0; break; + } + } + } + catch (InvalidOperationException) { _interactive = false; } + return false; + } + + public void Render(int pid, ServerSample sample, ServerHistory history, + ServerLaunchOptions? launchOptions = null, string? status = null) { + if (_interactive) { + try { + var width = Math.Max(1, Console.WindowWidth - 1); + var height = Math.Max(1, Console.WindowHeight - 1); + var lines = FormatLines(pid, sample, history, width, launchOptions ?? _launchOptions) + .SelectMany(line => WrapLine(SafeText(line), width)).ToArray(); + if (status is not null) lines = WrapLine(SafeText(status), width).Concat(lines).ToArray(); + var tabLine = _tabs?.Layout(width); + var firstContentRow = tabLine is null ? 0 : 1; + var contentHeight = Math.Max(0, height - firstContentRow - 1); + _scrollOffset = Math.Clamp(_scrollOffset, 0, Math.Max(0, lines.Length - contentHeight)); + Console.SetCursorPosition(0, 0); + for (var row = 0; row < height; row++) { + var index = row - firstContentRow + _scrollOffset; + var text = row == 0 && tabLine is not null ? tabLine + : row == height - 1 + ? $"Left/Right: tabs | PgUp/PgDn: scroll | Q: stop all | {_scrollOffset + 1}-{Math.Min(lines.Length, _scrollOffset + contentHeight)}/{lines.Length}" + : index < lines.Length ? lines[index] : ""; + Console.Write(text[..Math.Min(text.Length, width)].PadRight(width)); + if (row + 1 < height) Console.WriteLine(); + } + return; + } + catch (Exception ex) when (ex is IOException or ArgumentOutOfRangeException or PlatformNotSupportedException) { + _interactive = false; + } + } + if (_lastPlainOutput.TryGetValue(pid, out var lastOutput) && lastOutput.Status == status + && sample.Time - lastOutput.Time < TimeSpan.FromSeconds(10)) return; + _lastPlainOutput[pid] = (sample.Time, status); + Console.WriteLine(SafeText((status is null ? "" : status + " | ") + string.Join(" | ", + FormatLines(pid, sample, history, 80, launchOptions ?? _launchOptions).TakeWhile(line => line.Length != 0)))); + } + + public static IReadOnlyList FormatLines(int pid, ServerSample sample, ServerHistory history, int width, + ServerLaunchOptions? launchOptions = null) { + string Number(double? value, string suffix) => value.HasValue + ? value.Value.ToString("F1", CultureInfo.InvariantCulture) + suffix : "unknown"; + var players = sample.Players.HasValue ? $"{sample.Players}/{sample.MaxPlayers}" : "unknown"; + var uptime = $"{(int)sample.Uptime.TotalDays}d {sample.Uptime:hh\\:mm\\:ss}"; + var chartWidth = Math.Clamp(width - 22, 1, 80); + var samples = history.Samples.ToArray(); + var actors = launchOptions?.ServerMods.Where(actor => !string.IsNullOrWhiteSpace(actor)).ToArray() ?? []; + string Port(int? port) => port?.ToString(CultureInfo.InvariantCulture) ?? "unknown"; + List lines = [ + $"{launchOptions?.Name ?? "Unchained Server"} | PID {pid} | Q / Ctrl+C: stop", + $"State {history.State} | Uptime {uptime} | Restarts 0 (no auto-restart)", + $"Players {players} | Map {sample.Map ?? "unknown"} | Game Type {sample.GameType ?? "unknown"}", + $"Game Port {Port(launchOptions?.GamePort)} | Query Port {Port(launchOptions?.QueryPort)} | Beacon Port {Port(launchOptions?.BeaconPort)} | RCON Port {Port(launchOptions?.RconPort)}", + $"Local IP {launchOptions?.LocalIp.IfNone("127.0.0.1") ?? "unknown"} | Mods {(actors.Length == 0 ? "-" : string.Join(", ", actors))}", + $"CPU {Number(sample.CpuPercent, "% host")} | Private memory {Number(sample.MemoryMiB, " MiB")}", + $"Peak memory {Number(sample.PeakMemoryMiB, " MiB")} | Handle Count {sample.HandleCount?.ToString(CultureInfo.InvariantCulture) ?? "unknown"}", + $"A2S {(sample.QueryAvailable ? "responding" : "unavailable (starting or query timeout)")} | {sample.Time:HH:mm:ss}", + "", "History (up to 12 hours; 10s samples; '?' = unknown):", + $"Uptime S=start U=up {StateHistory(samples, chartWidth)}", + $"CPU (0-100%) {Sparkline(samples.Select(s => s.CpuPercent), chartWidth, 100)}", + $"Memory {Sparkline(samples.Select(s => s.MemoryMiB), chartWidth)}", + $"Players {Sparkline(samples.Select(s => s.Players.HasValue ? (double?)s.Players.Value : null), chartWidth)}", + "", "Timeline (latest events):" + ]; + lines.AddRange(history.Events.TakeLast(8).Select(e => $" {e.Time:HH:mm:ss} {e.Message}")); + return lines; + } + + public static string Sparkline(IEnumerable values, int width, double? maximum = null) { + const string levels = ".:-=+*#%@"; + var samples = values.ToArray(); + var max = maximum ?? samples.Where(v => v.HasValue).Select(v => v!.Value).DefaultIfEmpty(0).Max(); + return new string(Compress(samples, width).Select(value => !value.HasValue ? '?' : levels[max <= 0 ? 0 + : (int)Math.Clamp(value.Value / max * (levels.Length - 1), 0, levels.Length - 1)]).ToArray()); + } + + private static IEnumerable Compress(T[] samples, int width) { + var count = Math.Min(samples.Length, Math.Max(1, width)); + for (var i = 0; i < count; i++) + yield return samples[(int)((long)(i + 1) * samples.Length / count) - 1]; + } + + private static string StateHistory(ServerSample[] samples, int width) => + new(Compress(samples, width).Select(sample => sample.Starting ? 'S' : 'U').ToArray()); + + public static string SafeText(string value) => new(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()); + + public static IEnumerable WrapLine(string line, int width) { + width = Math.Max(1, width); + if (line.Length == 0) yield return ""; + for (var offset = 0; offset < line.Length; offset += width) + yield return line.Substring(offset, Math.Min(width, line.Length - offset)); + } + + public void Dispose() { + _mouse?.Dispose(); + try { + Console.CursorVisible = _previousCursorVisible ?? true; + if (_interactive) Console.WriteLine(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException) { } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerGroupOptions.cs b/UnchainedLauncher.CLI/ServerGroupOptions.cs new file mode 100644 index 00000000..c3d2f3c8 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerGroupOptions.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace UnchainedLauncher.CLI; + +public sealed record NamedServerOptions(string Id, ServerOptions Options); + +public sealed class ServerGroupOptions : CliCommandOptions { + public IReadOnlyList Servers { get; } + public bool NoTui { get; } + + private ServerGroupOptions(IReadOnlyList servers, bool noTui) { + Servers = servers; + NoTui = noTui; + } + + internal static ServerGroupOptions Create(ServerConfiguration configuration, string directory) { + var servers = new List(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + var profiles = new HashSet(StringComparer.OrdinalIgnoreCase); + var ports = new HashSet(); + foreach (var (id, settings) in configuration.Servers!) { + if (!Regex.IsMatch(id, @"\A[A-Za-z0-9_-]+\z") || !ids.Add(id)) + throw new ArgumentException("Server IDs must be unique (case-insensitive) and contain only letters, digits, underscores, and hyphens."); + var suffix = settings!.SavedDirSuffix ?? id; + var options = new ServerOptions(new SharedLaunchOptions(configuration, directory, suffix, settings), settings, configuration.NoTui); + if (!profiles.Add(options.GameSavedDirSuffix)) + throw new ArgumentException("Each server must use a distinct saved-dir-suffix (case-insensitive, ignoring hyphens)."); + foreach (var port in new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }) { + if (!ports.Add(port)) throw new ArgumentException($"Port {port} is used by more than one server. Assign distinct game, ping, query, and RCON ports to every server."); + } + servers.Add(new NamedServerOptions(id, options)); + } + return new ServerGroupOptions(servers, configuration.NoTui); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerGroupRunner.cs b/UnchainedLauncher.CLI/ServerGroupRunner.cs new file mode 100644 index 00000000..9103f12b --- /dev/null +++ b/UnchainedLauncher.CLI/ServerGroupRunner.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Server.A2S; + +namespace UnchainedLauncher.CLI; + +internal sealed record ServerTabSnapshot(ServerSample Sample, ServerHistory History, string Status); + +internal static class ServerGroupRunner { + internal static async Task RunAsync(ServerGroupOptions group, CancellationTokenSource stop, + Func>? launch = null, + Func? query = null) { + launch ??= (options, token) => ServerLauncher.LaunchAsync(options, token); + query ??= ServerLauncher.CreateQuery; + using var monitoring = CancellationTokenSource.CreateLinkedTokenSource(stop.Token); + var processes = new List(); + var leases = new List(); + var tasks = new List(); + var snapshots = new ServerTabSnapshot?[group.Servers.Count]; + try { + // Reserve every instance before starting any processes. + foreach (var entry in group.Servers) leases.Add(entry.Options.InstancePaths.AcquireLease()); + // Core preparation changes CWD and installation files. Never launch in parallel. + foreach (var entry in group.Servers) { + stop.Token.ThrowIfCancellationRequested(); + Console.Error.WriteLine($"Starting server {entry.Id}..."); + processes.Add(await launch(entry.Options, stop.Token)); + } + + using var dashboard = new ServerDashboard(!group.NoTui, + tabNames: group.Servers.Select(entry => entry.Id).ToArray()); + for (var i = 0; i < processes.Count; i++) tasks.Add(MonitorAsync(i)); + + var launchOptions = group.Servers.Select(entry => entry.Options.ToServerLaunchOptions()).ToArray(); + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + while (true) { + if (dashboard.StopRequested()) stop.Cancel(); + for (var i = 0; i < snapshots.Length; i++) { + if (dashboard.IsInteractive && i != dashboard.SelectedTab) continue; + var snapshot = Volatile.Read(ref snapshots[i]); + if (snapshot is not null) dashboard.Render(processes[i].Id, snapshot.Sample, snapshot.History, + launchOptions[i], $"[{group.Servers[i].Id}] {snapshot.Status}"); + } + if (stop.IsCancellationRequested || tasks.All(task => task.IsCompleted)) break; + if (tasks.Any(task => task.IsFaulted)) + await Task.WhenAll(tasks.Where(task => task.IsFaulted)); + if (!await timer.WaitForNextTickAsync(stop.Token)) break; + } + monitoring.Cancel(); + await Task.WhenAll(tasks); + return stop.IsCancellationRequested ? 0 : processes.Select(process => process.ExitCode).FirstOrDefault(code => code != 0); + + async Task MonitorAsync(int index) { + var process = processes[index]; + var monitor = new ServerMonitor(process, query(group.Servers[index].Options)); + var last = new ServerSample(DateTimeOffset.Now, TimeSpan.Zero, null, null, null, null, null, false); + Volatile.Write(ref snapshots[index], new(last, monitor.History.Snapshot(), "Starting")); + await monitor.RunAsync((sample, history) => { + last = sample; + Volatile.Write(ref snapshots[index], new(sample, history.Snapshot(), "Running")); + }, monitoring.Token); + var status = process.HasExited ? $"Exited (code {process.ExitCode}); last recorded metrics" : "Stopping"; + Volatile.Write(ref snapshots[index], new(last, monitor.History.Snapshot(stopped: process.HasExited), status)); + } + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) { return 0; } + finally { + monitoring.Cancel(); + try { await Task.WhenAll(tasks); } + catch (Exception) { /* The main loop propagates monitor failures; cleanup must still stop every process. */ } + var failedStops = new List(); + foreach (var process in processes) { + try { + if (!process.HasExited) { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + catch (Exception) { failedStops.Add(process.Id); } + finally { process.Dispose(); } + } + foreach (var lease in leases) lease.Dispose(); + if (failedStops.Count != 0) + throw new InvalidOperationException($"Could not stop server processes: {string.Join(", ", failedStops)}. Stop them manually."); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerInstancePaths.cs b/UnchainedLauncher.CLI/ServerInstancePaths.cs new file mode 100644 index 00000000..1b505e24 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerInstancePaths.cs @@ -0,0 +1,15 @@ +using UnchainedLauncher.Core.Services.Mods; + +namespace UnchainedLauncher.CLI; + +public sealed record ServerInstancePaths(ProfileAssetPaths Assets) { + public string Root => Assets.Root; + public string Paks => Assets.Paks; + public string Plugins => Assets.Plugins; + public string ModMetadata => Path.Combine(Root, "cli_mod_manager.json"); + + internal IDisposable AcquireLease() => Assets.AcquireLease(); + + internal PakDir CreatePakDir(string installation, CancellationToken cancellationToken = default) => + Assets.CreatePakDir(installation, cancellationToken: cancellationToken); +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs new file mode 100644 index 00000000..6f127dfa --- /dev/null +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -0,0 +1,163 @@ +using StructuredINI; +using System.Diagnostics; +using System.Net; +using UnchainedLauncher.Core.INIModels.Game; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Services.Server.A2S; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.CLI; + +public static class ServerLauncher { + public static async Task LaunchAsync(CliLaunchOptions request, CancellationToken cancellationToken) { + var options = request.Shared; + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Game launching requires Windows (Core's DLL injector uses kernel32.dll)."); + var executable = Path.Combine(options.Chivalry2Path, FilePaths.GameBinPath); + var basePakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); + var instance = request.InstancePaths; + if (!File.Exists(executable)) throw new FileNotFoundException("Chivalry 2 shipping executable was not found. Check --chivalry2-path.", executable); + if (!Directory.Exists(basePakPath)) throw new DirectoryNotFoundException($"Chivalry 2 Paks directory was not found: {basePakPath}"); + + var registry = CreateRegistry(request); + + return await InInstallationDirectoryAsync(request, async () => { + var cachePath = instance.ModMetadata; + var codec = new ModManagerCodec(registry); + // Core reconciles physical ownership across hosts immediately before installing. + // CLI selection comes only from this request, never from a cached GUI selection. + var manager = CreateModManager(request, registry, cancellationToken); + Console.Error.WriteLine("Loading mod registries..."); + var fetched = await manager.UpdateModsList(); + cancellationToken.ThrowIfCancellationRequested(); + if (fetched.HasErrors) { + foreach (var error in fetched.Errors) Console.Error.WriteLine(error.Message); + throw new InvalidOperationException("Registry loading failed; refusing to launch with incomplete metadata."); + } + ValidateReleases(manager, options.Mods); + + if (options.Platform == InstallationType.Steam) { + var appIdPath = Path.Combine(options.Chivalry2Path, FilePaths.SteamAppIdPath); + if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); + } + var pluginUpdater = CreateDllSession(request, registry, manager, cancellationToken); + var preparer = CreateLaunchPreparer(request, manager, pluginUpdater, codec, cachePath, cancellationToken); + var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), + options.Chivalry2Path, pluginUpdater); + var result = await launcher.Launch(request.ToLaunchOptions()); + return result.Match( + Right: process => process, + // Do not print LaunchFailed.Message: it contains raw password/auth arguments. + Left: failure => throw new InvalidOperationException("Game launch or DLL injection failed. Check the installation, plugin, and mod files.", failure)); + }); + } + + internal static IChivalry2LaunchPreparer CreateLaunchPreparer(CliLaunchOptions request, + ModManager manager, DllLaunchSession pluginUpdater, ModManagerCodec codec, string cachePath, + CancellationToken cancellationToken = default) { + var dialogue = new ConsoleDialogue(); + var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); + var signing = SigPreparer.CreateForProfile(manager.PakDir, dialogue); + // Call preparers sequentially so each receives the previous result. + // Always save the managed-pak inventory, even after a partial installation. + return Chivalry2LaunchPreparer.Create(async launch => { + cancellationToken.ThrowIfCancellationRequested(); + var updated = await pluginUpdater.PrepareLaunch(launch); + cancellationToken.ThrowIfCancellationRequested(); + if (updated.IsNone) return LanguageExt.Option.None; + // DLL preparation can enable older pak releases with different dependencies. + ValidateReleases(manager, request.Shared.Mods); + try { + updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); + } + finally { + codec.SerializeFile(cachePath, manager); + } + cancellationToken.ThrowIfCancellationRequested(); + if (updated.IsNone || (await signing.PrepareLaunch(LanguageExt.Unit.Default)).IsNone) + return LanguageExt.Option.None; + cancellationToken.ThrowIfCancellationRequested(); + InitializeServerProfile(request); + return updated; + }); + } + + internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => + options.Shared.RegistryOptions.CreateRegistry(); + + internal static ModManager CreateModManager(CliLaunchOptions request, AggregateModRegistry registry, CancellationToken cancellationToken = default) { + return new ModManager(registry, request.InstancePaths.CreatePakDir(request.Shared.Chivalry2Path, cancellationToken), request.Shared.Mods.Distinct()); + } + + internal static DllLaunchSession CreateDllSession(CliLaunchOptions request, AggregateModRegistry registry, + IModManager manager, CancellationToken cancellationToken = default, HttpClient? client = null) => + new(registry, manager, request.InstancePaths.Plugins, + request.Shared.Dlls, request.Shared.UnsafeLoadAllDlls, cancellationToken, client, request.Shared.Assets.DllCache); + + internal static void InitializeServerProfile(CliLaunchOptions request, Func? configPath = null) { + if (request is not ServerOptions server) return; + + var directory = (configPath ?? FilePaths.Chiv2ConfigPath)(server.GameSavedDirSuffix); + // Match the GUI's startup defaults without rewriting unrelated server or player settings. + var writer = new StructuredINIWriter(); + if (!writer.BufferWrite(new TBLTitleScreen()) || !writer.BufferWrite(new TBLGameInstance())) + throw new IOException("Could not initialize the server INI profile; refusing to launch."); + var files = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Game"] = writer }; + server.Ini.BufferInto(files); + server.Settings.Rotation?.BufferInto(writer); + IniPatches.Write(directory, files); + } + + // Several existing Core preparers use installation-relative paths. + // This standalone CLI sets CWD only for preparation, then restores it. + internal static async Task InInstallationDirectoryAsync(CliLaunchOptions options, Func> action) { + var previousDirectory = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(options.Shared.Chivalry2Path); + try { + return await action(); + } + finally { + Directory.SetCurrentDirectory(previousDirectory); + } + } + + public static void ValidateReleases(IModManager manager, IEnumerable requested) { + foreach (var coordinate in requested) { + if (manager.GetRelease(coordinate).IsNone) + throw new ArgumentException($"Requested mod release was not found: {coordinate.Org}/{coordinate.ModuleName}:{coordinate.Version}"); + } + if (manager.GetLatestRelease(CommonMods.UnchainedMods).IsNone) + throw new InvalidOperationException("The registry must provide a stable Chiv2-Community/Unchained-Mods release."); + var releases = manager.GetEnabledAndDependencyReleases().ToArray(); + if (releases.GroupBy(r => (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) + throw new ArgumentException("Dependency resolution selected conflicting versions of a mod. Core currently selects the latest stable dependencies, including Unchained-Mods."); + foreach (var release in releases) { + foreach (var dependency in release.Info.EffectiveModDependencies) { + if (manager.GetLatestRelease(ModIdentifier.FromDependency(dependency)).IsNone) + throw new InvalidOperationException($"Missing stable dependency: {dependency.Organization}/{dependency.RepoName}"); + if (!releases.Any(r => r.Info.Organization == dependency.Organization && r.Info.RepoName == dependency.RepoName)) + throw new InvalidOperationException($"Core did not resolve dependency {dependency.Organization}/{dependency.RepoName} for the requested release. Its current resolver uses latest-release dependency metadata."); + } + } + var selected = releases.Select(ReleaseCoordinates.FromRelease).ToHashSet(); + var sorted = manager.SortByDependencies(releases).ToArray(); + if (!selected.SetEquals(sorted.Select(ReleaseCoordinates.FromRelease))) + throw new InvalidOperationException("Mod dependencies contain a cycle; refusing to install an incomplete pak set."); + var preceding = new HashSet(); + foreach (var release in sorted) { + if (release.Info.EffectiveModDependencies.Any(dependency => !preceding.Contains(ModIdentifier.FromDependency(dependency)))) + throw new InvalidOperationException("Selected mod dependencies contain a cycle or violate the resolved pak order; refusing to install."); + preceding.Add(ModIdentifier.FromRelease(release)); + } + } + + public static IA2S CreateQuery(ServerOptions options) { + var address = options.Settings.LocalIp is null ? IPAddress.Loopback : IPAddress.Parse(options.Settings.LocalIp); + if (address.Equals(IPAddress.Any)) address = IPAddress.Loopback; + return new A2S(new IPEndPoint(address, options.Settings.QueryPort)); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerMonitor.cs b/UnchainedLauncher.CLI/ServerMonitor.cs new file mode 100644 index 00000000..151acd09 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerMonitor.cs @@ -0,0 +1,126 @@ +using System.ComponentModel; +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Server.A2S; + +namespace UnchainedLauncher.CLI; + +public record ServerSample(DateTimeOffset Time, TimeSpan Uptime, double? CpuPercent, + double? MemoryMiB, int? Players, int? MaxPlayers, string? Map, bool QueryAvailable, + string? GameType = null, int? HandleCount = null, double? PeakMemoryMiB = null) { + public bool Starting { get; init; } +} +public record TimelineEvent(DateTimeOffset Time, string Message); + +public sealed class ServerHistory { + public const int MaxSamples = 12 * 60 * 60 / 10; + public const int MaxEvents = 50; + private readonly Queue _samples = new(); + private readonly Queue _events = new(); + private ServerSample? _previous; + private bool _hasQueryResponse; + private bool _stopped; + public string State => _stopped ? "Down" : _hasQueryResponse ? "Up" : "Starting"; + public IReadOnlyCollection Samples => _samples; + public IReadOnlyCollection Events => _events; + + internal ServerHistory Snapshot(bool stopped = false) { + var snapshot = new ServerHistory { _hasQueryResponse = _hasQueryResponse, _stopped = stopped }; + foreach (var sample in _samples) snapshot._samples.Enqueue(sample); + foreach (var entry in _events) snapshot._events.Enqueue(entry); + return snapshot; + } + + public void AddEvent(DateTimeOffset time, string message) { + _events.Enqueue(new TimelineEvent(time, message)); + while (_events.Count > MaxEvents) _events.Dequeue(); + } + + public void Add(ServerSample sample) { + var previous = _previous; + if (previous is null || previous.QueryAvailable != sample.QueryAvailable) + AddEvent(sample.Time, sample.QueryAvailable ? "A2S responding" : "A2S unavailable; waiting for server query response"); + if (sample.QueryAvailable) { + if (previous?.Map != sample.Map) AddEvent(sample.Time, $"Map: {sample.Map}"); + if (previous?.Players != sample.Players) AddEvent(sample.Time, $"Players: {sample.Players}/{sample.MaxPlayers}"); + } + _previous = sample; + _hasQueryResponse |= sample.QueryAvailable; + if (_samples.Count != 0 && sample.Time - _samples.Last().Time < TimeSpan.FromSeconds(10)) return; + _samples.Enqueue(sample with { Starting = !_hasQueryResponse }); + while (_samples.Count > MaxSamples) _samples.Dequeue(); + } +} + +public sealed class ServerMonitor(Process process, IA2S query) { + public ServerHistory History { get; } = new(); + + public async Task RunAsync(ServerDashboard dashboard, CancellationTokenSource stop) { + await RunAsync((sample, history) => { + if (dashboard.StopRequested()) stop.Cancel(); + dashboard.Render(process.Id, sample, history); + }, stop.Token); + Console.Error.WriteLine(History.Events.Last().Message); + } + + internal async Task RunAsync(Action render, CancellationToken stop) { + try { + DateTimeOffset started; + TimeSpan previousCpu; + try { + started = new DateTimeOffset(process.StartTime); + previousCpu = process.TotalProcessorTime; + } + catch (Exception ex) when ((ex is InvalidOperationException or Win32Exception) && process.HasExited) { + return; + } + var previousTime = Stopwatch.GetTimestamp(); + double? peakMemory = null; + History.AddEvent(DateTimeOffset.Now, $"Server started (PID {process.Id})"); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (!stop.IsCancellationRequested && !process.HasExited) { + A2SInfo? info = null; + try { + info = await query.InfoAsync().WaitAsync(stop); + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) { throw; } + catch (Exception) { + // Missing/invalid A2S replies are normal during startup and outages. + // Never convert an unavailable query into a zero player count. + } + if (process.HasExited) break; + double? cpu = null; + double? memory = null; + int? handles = null; + try { + process.Refresh(); + var nowTicks = Stopwatch.GetTimestamp(); + var elapsed = Stopwatch.GetElapsedTime(previousTime, nowTicks).TotalSeconds; + var totalCpu = process.TotalProcessorTime; + cpu = Math.Clamp((totalCpu - previousCpu).TotalSeconds / elapsed / System.Environment.ProcessorCount * 100, 0, 100); + memory = process.PrivateMemorySize64 / 1048576d; + peakMemory = Math.Max(peakMemory ?? 0, memory.Value); + handles = process.HandleCount; + previousCpu = totalCpu; + previousTime = nowTicks; + } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) { + if (process.HasExited) break; + } + var now = DateTimeOffset.Now; + var sample = new ServerSample(now, now - started, cpu, memory, + info?.Players, info?.MaxPlayers, info?.Map, info is not null, + info?.GameType, handles, peakMemory); + History.Add(sample); + render(sample, History); + if (!await timer.WaitForNextTickAsync(stop)) break; + } + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) { } + finally { + var message = stop.IsCancellationRequested ? "Stop requested" : process.HasExited + ? $"Server exited (code {process.ExitCode})" : "Monitoring ended"; + History.AddEvent(DateTimeOffset.Now, message); + // A group owns console output; individual monitors only record events. + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerTabs.cs b/UnchainedLauncher.CLI/ServerTabs.cs new file mode 100644 index 00000000..53fe6455 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerTabs.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using System.Text; + +namespace UnchainedLauncher.CLI; + +public sealed class ServerTabs(IReadOnlyList names) { + private readonly List<(int Start, int End, int Index)> _targets = []; + private int _first; + public int Selected { get; private set; } + + public void Move(int delta) { + if (names.Count != 0) Selected = (Selected + delta % names.Count + names.Count) % names.Count; + } + + public bool Click(int column, int row) { + if (row != 1) return false; + foreach (var target in _targets) { + if (column < target.Start || column > target.End) continue; + Selected = target.Index; + return true; + } + return false; + } + + public string Layout(int width) { + width = Math.Max(1, width); + var text = Build(); + if (_targets.All(target => target.Index != Selected)) { + _first = Selected; + text = Build(); + } + return text; + + string Build() { + _targets.Clear(); + var text = new StringBuilder(); + for (var i = _first; i < names.Count; i++) { + var label = i == Selected ? $">{names[i]}<" : $"[{names[i]}]"; + label = ServerDashboard.SafeText(label); + label = label[..Math.Min(label.Length, Math.Min(width, 24))]; + if (text.Length + label.Length > width) break; + _targets.Add((text.Length + 1, text.Length + label.Length, i)); + text.Append(label); + if (text.Length < width) text.Append(' '); + } + return text.ToString(); + } + } +} + +public sealed class TerminalMouseParser { + private string? _pending; + + public bool Feed(char value, out (int Column, int Row)? click) { + click = null; + if (value == '\x1b') { _pending = "\x1b"; return true; } + if (_pending is null) return false; + _pending += value; + if (_pending.Length > 64 || !(char.IsAsciiDigit(value) || value is '[' or '<' or ';' or 'M' or 'm')) { + _pending = null; + return false; + } + if (value is not ('M' or 'm')) return true; + var sequence = _pending; + _pending = null; + if (!sequence.StartsWith("\x1b[<", StringComparison.Ordinal) || value != 'M') return true; + var parts = sequence[3..^1].Split(';'); + if (parts.Length == 3 && parts[0] == "0" + && int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var column) + && int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var row) + && column > 0 && row > 0) click = (column, row); + return true; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/TerminalMouse.cs b/UnchainedLauncher.CLI/TerminalMouse.cs new file mode 100644 index 00000000..0013b5f2 --- /dev/null +++ b/UnchainedLauncher.CLI/TerminalMouse.cs @@ -0,0 +1,45 @@ +using System.Runtime.InteropServices; + +namespace UnchainedLauncher.CLI; + +// SGR mouse reports are supported by xterm-compatible terminals and Windows Terminal. +internal sealed class TerminalMouse : IDisposable { + private readonly List<(IntPtr Handle, uint Mode)> _modes = []; + private bool _enabled; + + internal TerminalMouse() { + try { + if (OperatingSystem.IsWindows()) { + if (!SetMode(-10, mode => (mode | 0x200u | 0x80u) & ~0x40u) + || !SetMode(-11, mode => mode | 0x4u)) { + Dispose(); + return; + } + } + Console.Write("\x1b[?1000h\x1b[?1006h"); + _enabled = true; + } + catch (IOException) { Dispose(); } + } + + private bool SetMode(int stream, Func update) { + var handle = GetStdHandle(stream); + if (!GetConsoleMode(handle, out var mode) || !SetConsoleMode(handle, update(mode))) return false; + _modes.Add((handle, mode)); + return true; + } + + public void Dispose() { + try { if (_enabled) Console.Write("\x1b[?1000l\x1b[?1006l"); } + catch (IOException) { } + finally { + _enabled = false; + foreach (var (handle, mode) in _modes) SetConsoleMode(handle, mode); + _modes.Clear(); + } + } + + [DllImport("kernel32.dll")] private static extern IntPtr GetStdHandle(int handle); + [DllImport("kernel32.dll")] private static extern bool GetConsoleMode(IntPtr handle, out uint mode); + [DllImport("kernel32.dll")] private static extern bool SetConsoleMode(IntPtr handle, uint mode); +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj b/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj new file mode 100644 index 00000000..46b43857 --- /dev/null +++ b/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/UnchainedLauncher.CLI/YamlConfiguration.cs b/UnchainedLauncher.CLI/YamlConfiguration.cs new file mode 100644 index 00000000..ca9e371f --- /dev/null +++ b/UnchainedLauncher.CLI/YamlConfiguration.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace UnchainedLauncher.CLI; + +public static class YamlConfiguration { + public static CliCommandOptions Load(string path, bool client) { + LaunchConfiguration configuration = client ? LoadDocument(path) : LoadDocument(path); + try { configuration.Validate(); } + catch (ValidationException ex) { throw new ArgumentException("Invalid configuration: " + ex.Message); } + var directory = Path.GetDirectoryName(Path.GetFullPath(path))!; + return configuration is ServerConfiguration { Servers: not null } group + ? ServerGroupOptions.Create(group, directory) + : CliLaunchOptions.FromConfiguration(configuration, directory); + } + + internal static T LoadDocument(string path) where T : class { + path = Path.GetFullPath(path); + try { + using var reader = File.OpenText(path); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(HyphenatedNamingConvention.Instance) + .WithTypeConverter(new InstallationTypeYamlConverter()) + .WithTypeConverter(new IgnoredYamlNodeConverter()) + .WithTypeConverter(new MapRotationEntryYamlConverter()) + .WithDuplicateKeyChecking() + .WithEnforceNullability() + .Build(); + var configuration = deserializer.Deserialize(reader); + if (configuration is null) throw new ArgumentException("Configuration must contain one YAML mapping."); + return configuration; + } + catch (YamlException ex) { + // Library errors can include passwords/tokens; report only location. + throw new ArgumentException($"Invalid YAML configuration or schema at line {ex.Start.Line}, column {ex.Start.Column}."); + } + catch (ValidationException ex) { + throw new ArgumentException("Invalid configuration: " + ex.Message); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + throw new ArgumentException($"Cannot read YAML configuration: {path}"); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/examples/client.yaml b/UnchainedLauncher.CLI/examples/client.yaml new file mode 100644 index 00000000..fd34ae63 --- /dev/null +++ b/UnchainedLauncher.CLI/examples/client.yaml @@ -0,0 +1,14 @@ +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam +saved-dir-suffix: MyClient + +mod-setup: + registries: [] + mods: [] + dlls: [] + +# Run with: UnchainedLauncher.CLI.exe client --config-path client.yaml +# A server block, if included, is ignored by the client command. +update-plugin: true +client: + game-args: [] diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml new file mode 100644 index 00000000..19856ad4 --- /dev/null +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -0,0 +1,66 @@ +# Paths are relative to this file, unless absolute. Change this installation path. +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam # Steam or EGS; required +map-profiles: + courtyard: + map-name: FFA_Courtyard + +# Run with: UnchainedLauncher.CLI.exe server --config-path server.yaml +# Paks live in %LOCALAPPDATA%/Chivalry 2/Saved_MyServer/Paks/. +# Plugins live in %LOCALAPPDATA%/Chivalry 2/Saved_MyServer/unchained/plugins/. +# Pak bytes are shared through the common verified cache and hard links. +# A client block, if included, is ignored by the server command. +server-browser-backend: https://servers.polehammer.net +update-plugin: true +allow-unstable-plugin: false +no-tui: false +game-args: [] # Common game/auth arguments; one YAML string per argument. + +server: + map-rotation: + - courtyard + - map-name: FFA_Wardenglade + saved-dir-suffix: MyServer + mod-setup: + registries: [] # Official GitHub registry is included automatically. + mods: + - Chiv2-Community/Unchained-Mods:v0.1.0 + - Chiv2-Community/Chiv2Turbo:v0.0.4 + dlls: + - Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 + ini: + Game: + /Script/TBL.TBLGameMode: + BotBackfillEnabled: true + ClassLimits: + - Class: Poleman + ClassLimitPercent: 10 + - Class: Archer + ClassLimitPercent: 0 + name: My Unchained Server + description: Example server + password: '' + starting-map: FFA_Courtyard + game-port: 7777 + ping-port: 3075 + query-port: 7071 + rcon-port: 9001 + headless: true # Rendering requires headless: false AND desync-patch: false. + register: false + # local-ip: 192.168.1.10 # Optional override; local IPv4 is auto-detected. + mod-actors: + - /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C + desync-patch: true # Default true; explicitly set false to disable. + use-backend-banlist: true # Default true; explicitly set false to disable. + censor-mode: none # none, standard, sex, zealous + game-args: [] # Additional server-only arguments, appended after common game-args. + # ini patches only specified fields in the saved profile; other fields are preserved. + # Optional Discord integration: token and at least one channel are required. + # discord: + # bot-token: 'replace-with-your-bot-token' + # admin-channel-id: '123456789012345678' + # general-channel-id: '234567890123456789' + # dashboard-channel-id: '345678901234567890' + # event-log-channel-id: '456789012345678901' + # admin-role-id: '567890123456789012' + # mention-admins: true diff --git a/UnchainedLauncher.CLI/examples/servers.yaml b/UnchainedLauncher.CLI/examples/servers.yaml new file mode 100644 index 00000000..ea114211 --- /dev/null +++ b/UnchainedLauncher.CLI/examples/servers.yaml @@ -0,0 +1,65 @@ +# Run: UnchainedLauncher.CLI.exe server --config-path servers.yaml +# Paths resolve relative to this file. Each server has independent selections and files. +# Active paks: %LOCALAPPDATA%/Chivalry 2/Saved_/Paks/. +# Active plugins: %LOCALAPPDATA%/Chivalry 2/Saved_/unchained/plugins/. +# Pak bytes are shared through the common content-addressed cache and hard links. +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam +map-profiles: + courtyard: + map-name: FFA_Courtyard +server-browser-backend: https://servers.polehammer.net +update-plugin: true +allow-unstable-plugin: false +no-tui: false +game-args: [] +servers: + courtyard: + map-rotation: + - courtyard + - map-name: FFA_Wardenglade + # saved-dir-suffix defaults to courtyard (the server key). + mod-setup: + registries: [] # Official registry is included automatically. + mods: + - Chiv2-Community/Unchained-Mods:v0.1.0 + - Chiv2-Community/Chiv2Turbo:v0.0.4 + dlls: + - Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 + ini: + Game: + /Script/TBL.TBLGameMode: + BotBackfillEnabled: true + ClassLimits: + - Class: Poleman + ClassLimitPercent: 10 + - Class: Archer + ClassLimitPercent: 0 + name: Courtyard FFA + starting-map: FFA_Courtyard + game-port: 7777 + ping-port: 3075 + query-port: 7071 + rcon-port: 9001 + mod-actors: + - /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C + arena: + map-rotation: + - courtyard + - map-name: FFA_Bazaar + mod-setup: + registries: [] + mods: + - Chiv2-Community/Unchained-Mods:v0.1.0 + - Chiv2-Community/Chiv2Turbo:v0.0.4 + dlls: + - Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 + name: Arena FFA + starting-map: FFA_Courtyard # Replace with an installed map's travel name. + saved-dir-suffix: ArenaProfile # Optional explicit per-server profile. + game-port: 7778 + ping-port: 3076 + query-port: 7072 + rcon-port: 9002 + mod-actors: + - /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs index 14ee208c..d13a6359 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs @@ -21,6 +21,123 @@ public sealed class DllManagerTests : IDisposable { private DllManager Manager() => new(PluginPath, StatePath, _http); + [Fact] + public async Task ProfilesShareCachedDllIdentityAndDownloadOnce() { + Index(Release()); + var cacheRoot = Path.Combine(_dir, "shared-cache"); + var first = new DllManager(Path.Combine(_dir, "first", "Plugins"), null, _http, cacheDirectory: cacheRoot); + var second = new DllManager(Path.Combine(_dir, "second", "Plugins"), null, _http, cacheDirectory: cacheRoot); + await first.RefreshAsync(); + await second.RefreshAsync(); + first.SetEnabled(Release(), true); + second.SetEnabled(Release(), true); + var downloads = 0; + _handler.OnBinaryRequest = () => Interlocked.Increment(ref downloads); + await Task.WhenAll(Task.Run(() => first.PrepareAsync()), Task.Run(() => second.PrepareAsync())); + Assert.Equal(1, downloads); + var cache = Assert.Single(Directory.GetFiles(cacheRoot, "*.dll", SearchOption.AllDirectories)); + var firstPath = Assert.Single(Directory.GetFiles(Path.Combine(_dir, "first"), "*.dll", SearchOption.AllDirectories)); + var secondPath = Assert.Single(Directory.GetFiles(Path.Combine(_dir, "second"), "*.dll", SearchOption.AllDirectories)); + AssertSameFile(cache, firstPath); + AssertSameFile(cache, secondPath); + File.Delete(firstPath); + Assert.Equal(_binary, File.ReadAllBytes(secondPath)); + Assert.Equal(_binary, File.ReadAllBytes(cache)); + } + + [Fact] + public async Task ProfileCanLinkWhilePeerHoldsDllOpenWithoutDeleteSharing() { + Index(Release()); + var first = Manager(); + await first.RefreshAsync(); + await first.PrepareAsync(); + var path = Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + using var held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var secondRoot = Path.Combine(_dir, "second", "Plugins"); + var second = new DllManager(secondRoot, null, _http, cacheDirectory: Path.Combine(_dir, ".cache", "dlls")); + await second.RefreshAsync(); + second.SetEnabled(Release(), true); + await second.PrepareAsync(allowDownloads: false); + var secondPath = Assert.Single(Directory.GetFiles(secondRoot, "*.dll", SearchOption.AllDirectories)); + AssertSameFile(path, secondPath); + Assert.Equal(_binary, File.ReadAllBytes(secondPath)); + } + + private static void AssertSameFile(string first, string second) { + if (OperatingSystem.IsWindows()) { + static string Identity(string path) { + using var file = File.OpenRead(path); + Assert.True(GetFileInformationByHandle(file.SafeFileHandle, out var info)); + return $"{info.Volume}:{info.IndexHigh}:{info.IndexLow}"; + } + Assert.Equal(Identity(first), Identity(second)); + } + else if (OperatingSystem.IsLinux()) { + static string Identity(string path) { + var start = new System.Diagnostics.ProcessStartInfo("stat") { RedirectStandardOutput = true }; + start.ArgumentList.Add("--format=%d:%i"); + start.ArgumentList.Add("--"); + start.ArgumentList.Add(path); + using var process = System.Diagnostics.Process.Start(start)!; + var identity = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + Assert.Equal(0, process.ExitCode); + return identity; + } + Assert.Equal(Identity(first), Identity(second)); + } + } + + [Fact] + public async Task CrossVolumeDllRestorationCopiesValidatedCacheWithoutDownloading() { + var otherVolume = OperatingSystem.IsWindows() ? @"Z:\dev\shm" : "/dev/shm"; + if (!Directory.Exists(otherVolume)) return; + var plugins = Path.Combine(otherVolume, "dll-copy-test-" + Guid.NewGuid().ToString("N")); + try { + Index(Release()); + var first = Manager(); + await first.RefreshAsync(); + await first.PrepareAsync(); + var cacheRoot = Path.Combine(_dir, ".cache", "dlls"); + var cache = Assert.Single(Directory.GetFiles(cacheRoot, "*.dll", SearchOption.AllDirectories)); + var second = new DllManager(plugins, null, _http, cacheDirectory: cacheRoot); + await second.RefreshAsync(); + second.SetEnabled(Release(), true); + await second.PrepareAsync(allowDownloads: false); + var installed = Assert.Single(Directory.GetFiles(plugins, "*.dll", SearchOption.AllDirectories)); + // Deliberately corrupt the test cache to prove the fallback is an independent copy. + File.WriteAllText(cache, "tampered test cache"); + Assert.Equal(_binary, File.ReadAllBytes(installed)); + Assert.Empty(Directory.GetFiles(plugins, "*.tmp", SearchOption.AllDirectories)); + } + finally { if (Directory.Exists(plugins)) Directory.Delete(plugins, true); } + } + + [Fact] + public async Task WaitingForDllCacheLeaseCanBeCancelledWithoutDownloading() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + var directory = Path.Combine(_dir, ".cache", "dlls", Release().Sha256); + Directory.CreateDirectory(directory); + using var held = new FileStream(Path.Combine(directory, ".lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + _handler.OnBinaryRequest = () => throw new InvalidOperationException("Cancelled cache waiter must not download."); + await Assert.ThrowsAnyAsync(() => manager.PrepareAsync(cancellationToken: stop.Token) + .WaitAsync(TimeSpan.FromSeconds(5))); + Assert.False(Directory.Exists(PluginPath)); + } + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct FileInformation { + public uint Attributes, CreationLow, CreationHigh, AccessLow, AccessHigh, WriteLow, WriteHigh, + Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle(Microsoft.Win32.SafeHandles.SafeFileHandle file, out FileInformation information); + [Fact] public async Task RemovedReleaseCanBeDisabledButNeverReenabled() { Index(Release()); @@ -870,13 +987,13 @@ public async Task ActivePublicationCollisionPreservesUnknownFileAndBlocksPrepara reached = true; Assert.Equal(installed, path); Assert.False(File.Exists(path)); - // The production absence check and validated staging copy have - // completed. A separate writer wins the destination before rename. + // The production absence check and cache validation have completed. + // A separate writer wins the destination before link/copy publication. Task.Run(() => File.WriteAllText(path, "unknown user version")).GetAwaiter().GetResult(); }; var prepare = typeof(DllManager).GetMethod("PrepareAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; - await Assert.ThrowsAsync(() => (Task)prepare.Invoke(manager, [false, false, publishing])!); + await Assert.ThrowsAsync(() => (Task)prepare.Invoke(manager, [false, false, publishing, CancellationToken.None])!); Assert.True(reached); Assert.Equal("unknown user version", File.ReadAllText(installed)); Assert.Equal(_binary, File.ReadAllBytes(cache)); diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs index 2c190925..f554f397 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs @@ -27,7 +27,11 @@ public async Task FormSubmissionWritesAndReadsRequiredManifest(bool editing) { var root = Path.Combine(Path.GetTempPath(), "LocalReleaseForm_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { - var registry = new LocalModRegistry(Path.Combine(root, "registry")); + // The form test does not exercise the view's async-void list refresh. + // Keep it from reading manifests after the fixture starts cleanup. + var registryMock = new Mock(Path.Combine(root, "registry")) { CallBase = true }; + registryMock.Setup(source => source.GetAllMods()).ReturnsAsync(new GetAllModsResult([], [])); + var registry = registryMock.Object; var pak = Path.Combine(root, "source.pak"); await File.WriteAllTextAsync(pak, "test pak"); RegistryReleaseFormVM form; diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakDirTest.cs b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakDirTest.cs index 89b75e90..4bb81c66 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakDirTest.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakDirTest.cs @@ -451,8 +451,7 @@ public async Task InstallModSet_PakNameCollision_UsesTextualSuccessor() { PrepareTestDirWithSig(thisTestPath); // Create an unmanaged pak file with a conflicting name - // The lexicographic prefix will make the actual name qa__-__TestMod.pak - File.WriteAllText(Path.Join(thisTestPath, "qa__-__TestMod.pak"), "unmanaged pak contents"); + File.WriteAllText(Path.Join(thisTestPath, "TestMod_0_P.pak"), "unmanaged pak contents"); var pd = new Core.Services.Mods.PakDir(thisTestPath, Enumerable.Empty()); var coords = new ReleaseCoordinates("Org", "TestMod", "1.0.0"); @@ -468,10 +467,8 @@ public async Task InstallModSet_PakNameCollision_UsesTextualSuccessor() { Assert.Single(pd.ManagedPaks); var installedPakName = pd.ManagedPaks.First().PakFileName; - // Should not be exactly the same as the conflicting file - Assert.NotEqual("qa__-__TestMod.pak", installedPakName); - // But should still contain the module name - Assert.Contains("TestMod", installedPakName); + Assert.Equal("TestMod (1)_0_P.pak", installedPakName); + Assert.Equal("unmanaged pak contents", File.ReadAllText(Path.Join(thisTestPath, "TestMod_0_P.pak"))); } [Fact] diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs index 01cab1a9..1f0c1725 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs @@ -41,24 +41,24 @@ private void AssertUnmanagedUnchanged() { [InlineData(false)] [InlineData(true)] public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool uppercaseCollision) { - Occupy("q__-__Dependency.pak"); - Occupy(uppercaseCollision ? "QZ__-__DEPENDENCY.pak" : "qz__-__Dependency.pak"); - Occupy("qz__-__Dependency (1).pak"); - Occupy("qy__-__Dependency.pak"); - Occupy("qz__-__Dependent.pak"); + Occupy("Dependency_0_P.pak"); + Occupy(uppercaseCollision ? "DEPENDENCY_1_P.pak" : "Dependency_1_P.pak"); + Occupy("Dependency (1)_1_P.pak"); + Occupy("Dependent_0_P.pak"); + Occupy("Dependent_1_P.pak"); var dir = new ManagedPakDirectory(_dir, []); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); var original = Assert.Single(dir.ManagedPaks); - Assert.Equal("q__-__Dependency (1).pak", original.PakFileName); + Assert.Equal("Dependency (1)_0_P.pak", original.PakFileName); var originalPath = Path.Combine(_dir, original.PakFileName); var bytes = File.ReadAllBytes(originalPath); var sigBytes = File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig")); - (await Install(dir, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(2, _writes); - Assert.Equal(new[] { _dependent, _dependency }, dir.ManagedPaks.OrderBy(p => p.PakFileName, StringComparer.OrdinalIgnoreCase).Select(p => p.Coordinates)); + Assert.Equal(new[] { _dependent, _dependency }, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.Coordinates)); var moved = dir.ManagedPaks.Single(p => p.Coordinates == _dependency); - Assert.Equal("qz__-__Dependency (2).pak", moved.PakFileName); + Assert.Equal("Dependency (2)_1_P.pak", moved.PakFileName); Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(_dir, moved.PakFileName))); Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(Path.Combine(_dir, moved.PakFileName), ".sig"))); Assert.False(File.Exists(originalPath)); @@ -66,7 +66,7 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool var names = dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName).ToArray(); // Reconstructing persisted inventory and retrying must not rename or download anything. dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); - (await Install(dir, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(names, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName)); Assert.Equal(2, _writes); @@ -75,13 +75,13 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool foreach (var (name, content) in _unmanaged) File.WriteAllBytes(Path.Combine(freshPath, name), content); File.WriteAllText(Path.Combine(freshPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); var fresh = new ManagedPakDirectory(freshPath, []); - (await Install(fresh, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(fresh, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(names, fresh.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName)); - (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); - Assert.Equal(new[] { _dependency, _dependent }, dir.ManagedPaks.OrderBy(p => p.PakFileName, StringComparer.OrdinalIgnoreCase).Select(p => p.Coordinates)); - Assert.Equal("qy__-__Dependency (1).pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependency).PakFileName); - Assert.Equal("qz__-__Dependent (1).pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependent).PakFileName); + (await Install(dir, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(new[] { _dependency, _dependent }, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.Coordinates)); + Assert.Equal("Dependency (1)_0_P.pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependency).PakFileName); + Assert.Equal("Dependent (1)_1_P.pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependent).PakFileName); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(original.PakFileName, Assert.Single(dir.ManagedPaks).PakFileName); Assert.Equal(4, _writes); // Only the two original downloads and two fresh-install downloads. @@ -92,18 +92,177 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool [InlineData(false)] [InlineData(true)] public async Task CollisionRelocationFailureReturnsErrorWithoutPublishingNewInventory(bool signatureFailure) { - Occupy("q__-__Dependency.pak"); - Occupy("qz__-__Dependency.pak"); + Occupy("Dependency_0_P.pak"); + Occupy("Dependency_1_P.pak"); var dir = new ManagedPakDirectory(_dir, []); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); var original = Assert.Single(dir.ManagedPaks); - var blocked = Path.Combine(_dir, "qz__-__Dependency (1).pak"); - Directory.CreateDirectory(signatureFailure ? Path.ChangeExtension(blocked, ".sig") : blocked); - var results = await Install(dir, _dependency, _dependent); + var originalPath = Path.Combine(_dir, original.PakFileName); + var bytes = File.ReadAllBytes(originalPath); + var sigBytes = File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig")); + var blocked = Path.Combine(_dir, "Dependency (1)_1_P.pak"); + var obstruction = signatureFailure ? Path.ChangeExtension(blocked, ".sig") : blocked; + Directory.CreateDirectory(obstruction); + var results = await Install(dir, _dependent, _dependency); Assert.Contains(results, r => r.IsLeft); Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); + Assert.Equal(bytes, File.ReadAllBytes(originalPath)); + Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig"))); + if (!signatureFailure) Assert.False(File.Exists(Path.ChangeExtension(blocked, ".sig"))); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); + Directory.Delete(obstruction); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var moved = dir.ManagedPaks.Single(p => p.Coordinates == _dependency); + Assert.Equal("Dependency (1)_1_P.pak", moved.PakFileName); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(Path.Combine(_dir, moved.PakFileName), ".sig"))); + Assert.False(File.Exists(originalPath)); + Assert.False(File.Exists(Path.ChangeExtension(originalPath, ".sig"))); + Assert.Single(Directory.GetFiles(_dir, "Dependency*.pak").Where(p => !_unmanaged.ContainsKey(Path.GetFileName(p)))); + Assert.Equal(2, _writes); + AssertUnmanagedUnchanged(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LegacyMigrationPreservesSignatureOnlyCollisionsAcrossRestart(bool uppercaseCollision) { + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(Path.ChangeExtension(source, ".sig"), "owned signature"); + var unmanagedSig = uppercaseCollision ? "DEPENDENCY_0_P.sig" : "Dependency_0_P.sig"; + var unmanagedBytes = System.Text.Encoding.UTF8.GetBytes("unmanaged signature"); + _unmanaged.Add(unmanagedSig, unmanagedBytes); + File.WriteAllBytes(Path.Combine(_dir, unmanagedSig), unmanagedBytes); + var dir = new ManagedPakDirectory(_dir, [legacy]); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var migrated = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency (1)_0_P.pak", migrated.PakFileName); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(migrated, Assert.Single(dir.ManagedPaks)); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, migrated.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(_dir, migrated.PakFileName), ".sig"))); + Assert.False(File.Exists(source)); + Assert.False(File.Exists(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(0, _writes); AssertUnmanagedUnchanged(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CachedLegacyPayloadSurvivesFailedMigrationAndRestart(bool signatureFailure) { + var bytes = System.Text.Encoding.UTF8.GetBytes("immutable cache payload"); + var hash = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(bytes)).ToLowerInvariant(); + var request = new ModInstallRequest(_dependency, path => { + _writes++; + return LanguageExt.Prelude.RightAsync(new FileWriter(path, new MemoryStream(bytes), bytes.Length)); + }, hash); + var cache = new PakCache(Path.Combine(_dir, "cache")); + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + (await cache.Install(request, source, LanguageExt.Prelude.None)).IfLeft(e => throw e); + File.WriteAllText(Path.ChangeExtension(source, ".sig"), "owned signature"); + var target = Path.Combine(_dir, "Dependency_0_P.pak"); + var obstruction = signatureFailure ? Path.ChangeExtension(target, ".sig") : target; + Directory.CreateDirectory(obstruction); + var dir = new ManagedPakDirectory(_dir, [legacy], cachePath: cache.Root); + var failed = await dir.InstallModSet([request], LanguageExt.Prelude.None).ToListAsync(); + Assert.Contains(failed, r => r.IsLeft); + Assert.Equal(legacy, Assert.Single(dir.ManagedPaks)); + Assert.Equal(bytes, File.ReadAllBytes(source)); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(cache.Root, hash, "mod.pak"))); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks, cachePath: cache.Root); + Directory.Delete(obstruction); + (await dir.InstallModSet([request], LanguageExt.Prelude.None).ToListAsync()).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal("Dependency_0_P.pak", Assert.Single(dir.ManagedPaks).PakFileName); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal(bytes, File.ReadAllBytes(target)); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(target, ".sig"))); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(cache.Root, hash, "mod.pak"))); + Assert.False(File.Exists(source)); + Assert.False(File.Exists(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(1, _writes); + } + + [Fact] + public async Task SignatureCleanupFailureKeepsRelocatedPayloadOwnedAcrossRestart() { + // Windows permits copying a read-only signature but rejects deleting it. + if (!OperatingSystem.IsWindows()) return; + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + var sourceSig = Path.ChangeExtension(source, ".sig"); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(sourceSig, "owned signature"); + File.SetAttributes(sourceSig, FileAttributes.ReadOnly); + try { + var dir = new ManagedPakDirectory(_dir, [legacy]); + Assert.Contains(await Install(dir, _dependency), r => r.IsLeft); + var moved = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency_0_P.pak", moved.PakFileName); + Assert.False(File.Exists(source)); + Assert.Equal("owned signature", File.ReadAllText(sourceSig)); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(moved, Assert.Single(dir.ManagedPaks)); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.Combine(_dir, "Dependency_0_P.sig"))); + Assert.Equal(0, _writes); + } + finally { + foreach (var signature in Directory.GetFiles(_dir, "*.sig")) File.SetAttributes(signature, FileAttributes.Normal); + } + } + + [Fact] + public async Task FailedSignatureRollbackPreservesSourceOwnershipAndBothSignatures() { + if (!OperatingSystem.IsWindows()) return; + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + var sourceSig = Path.ChangeExtension(source, ".sig"); + var target = Path.Combine(_dir, "Dependency_0_P.pak"); + var targetSig = Path.ChangeExtension(target, ".sig"); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(sourceSig, "owned signature"); + // File.Copy retains ReadOnly, so cleanup of the staged signature also fails. + File.SetAttributes(sourceSig, FileAttributes.ReadOnly); + Directory.CreateDirectory(target); + try { + var dir = new ManagedPakDirectory(_dir, [legacy]); + var failed = await Install(dir, _dependency); + Assert.Contains(failed, r => r.IsLeft); + failed.ForEach(r => r.IfLeft(e => Assert.Contains("signature cleanup also failed", e.ToString()))); + Assert.Equal(legacy, Assert.Single(dir.ManagedPaks)); + Assert.Equal("owned payload", File.ReadAllText(source)); + Assert.Equal("owned signature", File.ReadAllText(sourceSig)); + Assert.Equal("owned signature", File.ReadAllText(targetSig)); + + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + Directory.Delete(target); + File.SetAttributes(sourceSig, FileAttributes.Normal); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var moved = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency (1)_0_P.pak", moved.PakFileName); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.Combine(_dir, "Dependency (1)_0_P.sig"))); + Assert.Equal("owned signature", File.ReadAllText(targetSig)); + Assert.Equal(FileAttributes.ReadOnly, File.GetAttributes(targetSig) & FileAttributes.ReadOnly); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(moved, Assert.Single(dir.ManagedPaks)); + Assert.Equal(0, _writes); + } + finally { + foreach (var signature in Directory.GetFiles(_dir, "*.sig")) File.SetAttributes(signature, FileAttributes.Normal); + } + } + public void Dispose() => Directory.Delete(_dir, true); } \ No newline at end of file diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs index d39861e0..f91227c2 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs @@ -18,8 +18,8 @@ namespace UnchainedLauncher.Core.Tests.Unit.Services.Processes.Chivalry; public sealed class PakLaunchPreparationTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), "pak-launch-" + Guid.NewGuid().ToString("N")); private readonly ReleaseCoordinates _coords = new("Chiv2-Community", "Unchained-Mods", "v0.1.0"); - private const string Preferred = "q__-__Unchained-Mods.pak"; - private const string Installed = "q__-__Unchained-Mods (1).pak"; + private const string Preferred = "Unchained-Mods_0_P.pak"; + private const string Installed = "Unchained-Mods (1)_0_P.pak"; private sealed class ImmediateContext : SynchronizationContext { public override void Post(SendOrPostCallback callback, object? state) { var previous = Current; diff --git a/UnchainedLauncher.Core/JsonModels/DllMetadata.cs b/UnchainedLauncher.Core/JsonModels/DllMetadata.cs new file mode 100644 index 00000000..a8f9affd --- /dev/null +++ b/UnchainedLauncher.Core/JsonModels/DllMetadata.cs @@ -0,0 +1,56 @@ +using Semver; +using System.Text.Json.Serialization; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.Core.JsonModels; + +// Matches zero-infra-mod-registry prepare-v1 and C2ModRegistry/dll_db. +public sealed record DllPackage( + [property: JsonPropertyName("latest_release_info"), JsonRequired] DllInfo LatestReleaseInfo, + [property: JsonPropertyName("releases"), JsonRequired] List Releases) { + [JsonIgnore] + public DllRelease? LatestRelease => Releases.Where(release => release.Version?.IsRelease == true) + .OrderByDescending(release => release.Version).FirstOrDefault() + ?? Releases.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(); +} + +public sealed record DllRelease( + [property: JsonPropertyName("tag"), JsonRequired] string Tag, + [property: JsonPropertyName("hash"), JsonRequired] string Hash, + [property: JsonPropertyName("dll_file_name"), JsonRequired] string DllFileName, + [property: JsonPropertyName("download_url"), JsonRequired] string DownloadUrl, + [property: JsonPropertyName("size"), JsonRequired] long Size, + [property: JsonPropertyName("release_date"), JsonRequired] DateTimeOffset ReleaseDate, + [property: JsonPropertyName("info"), JsonRequired] DllInfo Info, + [property: JsonPropertyName("release_notes_markdown")] string? ReleaseNotesMarkdown) { + [JsonIgnore] public SemVersion? Version => SemVersion.TryParse(Tag, SemVersionStyles.AllowV, out var version) ? version : null; + [JsonIgnore] internal Services.Dlls.DllRelease? RegisteredRelease { get; init; } + + public Services.Dlls.DllRelease ToManagedRelease() => RegisteredRelease ?? new( + Info.Identifier.Org, Info.Identifier.ModuleName, Version?.ToString() ?? throw new InvalidDataException("Invalid DLL version."), + DownloadUrl, Hash.ToLowerInvariant(), Size, + new Services.Dlls.PluginInfo(Info.Name, Info.Description, Info.Authors, Info.PluginDependencies, + Info.IconUrl, Info.ImageUrls, Info.ModDependencies, Info.RepoUrl, Info.Target), + Tag, ReleaseDate, ReleaseNotesMarkdown, DllFileName); +} + +public sealed record DllInfo { + [JsonPropertyName("repo_url"), JsonRequired] public required string RepoUrl { get; init; } + [JsonPropertyName("name"), JsonRequired] public required string Name { get; init; } + [JsonPropertyName("description"), JsonRequired] public required string Description { get; init; } + [JsonPropertyName("authors"), JsonRequired] public required List Authors { get; init; } + [JsonPropertyName("target")] public string Target { get; init; } = "shared"; + [JsonPropertyName("icon_url")] public string? IconUrl { get; init; } + [JsonPropertyName("image_urls")] public List ImageUrls { get; init; } = []; + [JsonPropertyName("plugin_dependencies")] public List PluginDependencies { get; init; } = []; + [JsonPropertyName("mod_dependencies")] public List ModDependencies { get; init; } = []; + [JsonIgnore] public ModIdentifier Identifier => RepositoryIdentifier(RepoUrl); + + internal static ModIdentifier RepositoryIdentifier(string repoUrl) { + if (!Uri.TryCreate(repoUrl, UriKind.Absolute, out var uri) || uri.Scheme != "https" || uri.Host != "github.com" || uri.UserInfo.Length != 0) + throw new ArgumentException("Invalid DLL dependency/repository URL."); + return RegistryUtils.ParseModIdentifier(uri.AbsolutePath.Trim('/'), "/") + ?? throw new ArgumentException("Invalid DLL repository identifier."); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs index dba148a3..4ce7bc63 100644 --- a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs +++ b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; +using UnchainedLauncher.Core.Utilities; namespace UnchainedLauncher.Core.Services.Dlls { /// Only explicitly enabled, registry-backed releases may reach the injector. @@ -16,9 +17,10 @@ public sealed class DllManager { public const string DefaultPluginId = "Chiv2-Community/UnchainedPlugin"; private readonly HttpClient _http; private readonly string _root; + private readonly string _legacyCliRoot; private readonly string _cacheRoot; - private readonly string _statePath; - private readonly string _indexPath; + private readonly string? _statePath; + private readonly string? _indexPath; private readonly Func> _registryUrls; private string? _approvedSources; private readonly Dictionary _enabled; @@ -28,28 +30,29 @@ public sealed class DllManager { public IReadOnlyList Releases { get; private set; } = []; public event EventHandler? SelectionChanged; - public DllManager(string pluginDirectory, string statePath, HttpClient http, + public DllManager(string pluginDirectory, string? statePath, HttpClient http, Func>? modReleases = null, Func>? registryUrls = null, string? cacheDirectory = null) { _registryUrls = registryUrls ?? (() => [DefaultDatabaseUrl]); _modReleases = modReleases ?? (() => []); var plugins = Path.TrimEndingDirectorySeparator(Path.GetFullPath(pluginDirectory)); _root = Path.Combine(plugins, "Managed"); + _legacyCliRoot = Path.Combine(plugins, ".dll-managed"); _cacheRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(cacheDirectory ?? Path.Combine(Path.GetDirectoryName(plugins)!, ".cache", "dlls"))); if (_cacheRoot.Equals(plugins, StringComparison.OrdinalIgnoreCase) || _cacheRoot.StartsWith(plugins + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("DLL cache must be outside Plugins.", nameof(cacheDirectory)); _statePath = statePath; - _indexPath = statePath + ".index.json"; + _indexPath = statePath == null ? null : statePath + ".index.json"; _http = http; - _defaultPending = !File.Exists(statePath); + _defaultPending = statePath != null && !File.Exists(statePath); var enabled = File.Exists(statePath) ? JsonSerializer.Deserialize>(File.ReadAllText(statePath)) ?? throw new InvalidDataException("Invalid DLL selection file.") : new Dictionary(); _enabled = new Dictionary(enabled, StringComparer.OrdinalIgnoreCase); - if (File.Exists(_indexPath) && File.Exists(_indexPath + ".sources") && + if (_indexPath != null && File.Exists(_indexPath) && File.Exists(_indexPath + ".sources") && File.ReadAllText(_indexPath + ".sources") == SourceSignature()) { _approvedSources = SourceSignature(); Releases = ParseIndex(File.ReadAllText(_indexPath)); @@ -73,6 +76,19 @@ private static IReadOnlyList ValidateReleases(IReadOnlyList catalog, + IReadOnlyList selected, HttpClient http, + Func> modReleases, + string? cacheDirectory = null) { + var manager = new DllManager(pluginDirectory, null, http, modReleases, () => [], cacheDirectory) { + Releases = ValidateReleases(catalog).ToArray(), + _approvedSources = "[]" + }; + foreach (var release in selected) manager.SetEnabled(release, true); + return manager; + } + public async Task RefreshAsync() { var sources = RegistryUrls(); var signature = JsonSerializer.Serialize(sources); @@ -89,9 +105,11 @@ public async Task RefreshAsync() { lock (_gate) { if (SourceSignature() != signature) throw new InvalidOperationException("DLL registry settings changed during refresh. Refresh again."); // Invalidate the old source marker before replacing the cached approvals. - WriteAtomic(_indexPath + ".sources", ""); - WriteAtomic(_indexPath, json); - WriteAtomic(_indexPath + ".sources", signature); + if (_indexPath != null) { + WriteAtomic(_indexPath + ".sources", ""); + WriteAtomic(_indexPath, json); + WriteAtomic(_indexPath + ".sources", signature); + } Releases = releases; _approvedSources = signature; EnableDefault(); @@ -107,12 +125,15 @@ private string[] RegistryUrls() => _registryUrls().Select(url => { private string SourceSignature() => JsonSerializer.Serialize(RegistryUrls()); - private async Task> ReadRegistryAsync(string databaseUrl) { + private Task> ReadRegistryAsync(string databaseUrl) => ReadRegistryAsync(_http, databaseUrl); + + public static async Task> ReadRegistryAsync(HttpClient http, string databaseUrl, + CancellationToken cancellationToken = default) { // The package list is authoritative. A stale legacy index must not // re-approve removed releases or block metadata/target updates. - using var listResponse = await _http.GetAsync(databaseUrl + "/plugin_list_index.txt"); + using var listResponse = await http.GetAsync(databaseUrl + "/plugin_list_index.txt", cancellationToken); if (listResponse.StatusCode == HttpStatusCode.NotFound) { - using var legacyResponse = await _http.GetAsync(databaseUrl + "/index.json"); + using var legacyResponse = await http.GetAsync(databaseUrl + "/index.json", cancellationToken); // Mod-only registries have neither format. return legacyResponse.StatusCode == HttpStatusCode.NotFound ? [] : ParseIndex(await ReadResponse(legacyResponse)); } @@ -122,7 +143,7 @@ private async Task> ReadRegistryAsync(string databaseU foreach (var package in packages) { if (!Regex.IsMatch(package, "^[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$") || !seen.Add(package)) throw new InvalidDataException("Invalid or duplicate plugin package identifier."); - using var packageResponse = await _http.GetAsync($"{databaseUrl}/packages/{package}.json"); + using var packageResponse = await http.GetAsync($"{databaseUrl}/packages/{package}.json", cancellationToken); foreach (var release in ParsePackage(await ReadResponse(packageResponse), package)) merged.Add(release.Key, release); } @@ -157,7 +178,7 @@ private static async Task ReadResponse(HttpResponseMessage response) { return await response.Content.ReadAsStringAsync(); } - private static string Canonical(DllRelease release) => JsonSerializer.Serialize(release with { + internal static string Canonical(DllRelease release) => JsonSerializer.Serialize(release with { Organization = release.Organization.ToLowerInvariant(), Name = release.Name.ToLowerInvariant(), Tag = release.Tag ?? release.Version, @@ -229,7 +250,9 @@ public void SetEnabled(DllRelease release, bool enabled) { SelectionChanged?.Invoke(this, EventArgs.Empty); } - private void Save() => WriteAtomic(_statePath, JsonSerializer.Serialize(_enabled)); + private void Save() { + if (_statePath != null) WriteAtomic(_statePath, JsonSerializer.Serialize(_enabled)); + } private static void WriteAtomic(string path, string contents) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); @@ -303,6 +326,7 @@ private void EnsureCurrentSources() { private string GetPath(DllRelease release) => Path.Combine(_root, release.Organization, release.Name, release.EffectiveFileName); private string GetLegacyPath(DllRelease release) => Path.Combine(_root, release.Organization, release.Name, release.Sha256, release.EffectiveFileName); + private string GetLegacyCliPath(DllRelease release) => Path.Combine(_legacyCliRoot, release.Sha256, release.EffectiveFileName); private string GetCachePath(DllRelease release) => Path.Combine(_cacheRoot, release.Sha256, release.EffectiveFileName); private static bool MatchesFile(string path, DllRelease release) { @@ -313,7 +337,8 @@ private static bool MatchesFile(string path, DllRelease release) { private DllRelease? ManagedReleaseAt(string path) => Releases.FirstOrDefault(release => (GetPath(release).Equals(path, StringComparison.OrdinalIgnoreCase) - || GetLegacyPath(release).Equals(path, StringComparison.OrdinalIgnoreCase)) + || GetLegacyPath(release).Equals(path, StringComparison.OrdinalIgnoreCase) + || GetLegacyCliPath(release).Equals(path, StringComparison.OrdinalIgnoreCase)) && MatchesFile(path, release)); public static void ValidateBytes(Stream stream, DllRelease release) { @@ -326,23 +351,27 @@ public static void ValidateBytes(Stream stream, DllRelease release) { stream.Position = 0; } - public Task PrepareAsync(bool server = false, bool allowDownloads = true) => - PrepareAsync(server, allowDownloads, null); + public Task PrepareAsync(bool server = false, bool allowDownloads = true, CancellationToken cancellationToken = default) => + PrepareAsync(server, allowDownloads, null, cancellationToken); - private async Task PrepareAsync(bool server, bool allowDownloads, Action? activePublishing) { + private async Task PrepareAsync(bool server, bool allowDownloads, Action? activePublishing, + CancellationToken cancellationToken = default) { var selected = SelectedReleases(server); // Fill and validate the cache before changing any installed DLLs. foreach (var release in selected) { + cancellationToken.ThrowIfCancellationRequested(); var path = GetPath(release); if (File.Exists(path) && !MatchesFile(path, release) && ManagedReleaseAt(path) == null) throw new InvalidDataException($"Unrecognized DLL at managed path; file left unchanged: {path}"); var cache = GetCachePath(release); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + using var cacheLease = await AssetCacheFiles.LockAsync(Path.Combine(Path.GetDirectoryName(cache)!, ".lock"), cancellationToken); if (File.Exists(cache)) { using var existing = File.OpenRead(cache); ValidateBytes(existing, release); continue; } - var installed = new[] { path, GetLegacyPath(release) } + var installed = new[] { path, GetLegacyPath(release), GetLegacyCliPath(release) } .FirstOrDefault(candidate => File.Exists(candidate) && MatchesFile(candidate, release)); if (installed != null) { CopyValidated(installed, cache, release); @@ -350,39 +379,43 @@ private async Task PrepareAsync(bool server, bool allowDownloads, Action } if (!allowDownloads) throw new InvalidOperationException($"Automatic plugin downloads are disabled; missing registered DLL: {release.Key}"); - Directory.CreateDirectory(Path.GetDirectoryName(cache)!); var temp = cache + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { - using var response = await _http.GetAsync(release.DownloadUrl, HttpCompletionOption.ResponseHeadersRead); + using var response = await _http.GetAsync(release.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); if (response.RequestMessage?.RequestUri?.Scheme != Uri.UriSchemeHttps) throw new InvalidDataException("Insecure DLL download redirect."); - await using (var source = await response.Content.ReadAsStreamAsync()) + await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken)) await using (var target = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { var buffer = new byte[81920]; long total = 0; int count; - while ((count = await source.ReadAsync(buffer)) != 0) { + while ((count = await source.ReadAsync(buffer, cancellationToken)) != 0) { total += count; if (total > release.Size) throw new InvalidDataException("DLL exceeds registered size."); - await target.WriteAsync(buffer.AsMemory(0, count)); + await target.WriteAsync(buffer.AsMemory(0, count), cancellationToken); } - await target.FlushAsync(); + await target.FlushAsync(cancellationToken); target.Position = 0; ValidateBytes(target, release); } - File.Move(temp, cache, true); + try { File.Move(temp, cache); } + catch (IOException) when (File.Exists(cache)) { + using var winner = File.OpenRead(cache); + ValidateBytes(winner, release); + } } finally { if (File.Exists(temp)) File.Delete(temp); } } lock (_gate) { + cancellationToken.ThrowIfCancellationRequested(); if (!SelectedReleases(server).SequenceEqual(selected)) throw new InvalidOperationException("Plugin selections or metadata changed during preparation. Launch again."); var active = selected.Select(GetPath).ToHashSet(StringComparer.OrdinalIgnoreCase); // Only validated files at paths owned by this manager may be archived. // Unknown names/versions, even inside Managed, are left untouched. - if (Directory.Exists(_root)) { - foreach (var path in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories).ToArray()) { + foreach (var managedRoot in new[] { _root, _legacyCliRoot }.Where(Directory.Exists)) { + foreach (var path in Directory.EnumerateFiles(managedRoot, "*", SearchOption.AllDirectories).ToArray()) { var release = ManagedReleaseAt(path); if (release == null || (active.Contains(path) && selected.Any(candidate => GetPath(candidate).Equals(path, StringComparison.OrdinalIgnoreCase) && MatchesFile(path, candidate)))) @@ -411,15 +444,28 @@ private static void CopyValidated(string source, string destination, DllRelease try { using var input = archive ? OpenForArchive(source) : new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read); - using (var output = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { - ValidateBytes(input, release); - input.CopyTo(output); - ValidateBytes(output, release); + ValidateBytes(input, release); + if (!archive && !overwrite) { + // The validated cache handle denies replacement on Windows until + // publication completes. Never overwrite shared DLL bytes in place. + beforePublish?.Invoke(); + if (AssetCacheFiles.TryCreateHardLink(destination, source)) return; + } + // A linked source and its cache object have the same identity. Do not + // replace that cache entry while the archival handle protects it. + using var existingCache = archive && File.Exists(destination) + ? new FileStream(destination, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete) + : null; + if (existingCache != null) ValidateBytes(existingCache, release); + else { + using (var output = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { + input.CopyTo(output); + ValidateBytes(output, release); + } + if (archive || overwrite) beforePublish?.Invoke(); + // Active publication must fail if another writer won the name. + File.Move(temp, destination, overwrite); } - beforePublish?.Invoke(); - // Cache entries may be replaced, but active publication must atomically - // fail if an external writer has created any destination file. - File.Move(temp, destination, overwrite); // Deterministic test boundary: the cache exists, and the validated // source must still deny writes, renames and pathname replacement. cachePublished?.Invoke(); diff --git a/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs b/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs index 3b3bdd37..f82401c3 100644 --- a/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs +++ b/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs @@ -88,7 +88,8 @@ public void Validate() { public static bool MatchesVersion(string version, string requirement) { var candidate = SemVersion.Parse(version, SemVersionStyles.Strict); if (string.IsNullOrEmpty(requirement)) throw new InvalidDataException("Missing dependency version."); - if (requirement.StartsWith('v')) requirement = requirement[1..]; + requirement = string.Join(",", requirement.Split(',').Select(part => + Regex.Replace(part.Trim(), @"^(\^|>=|<=|==|!=|>|<)?v", "$1"))); if (requirement.StartsWith('^')) { var lower = SemVersion.Parse(requirement[1..], SemVersionStyles.Strict); var upper = lower.Major != 0 ? new SemVersion(checked(lower.Major + 1), 0, 0) diff --git a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs new file mode 100644 index 00000000..cef74c4f --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs @@ -0,0 +1,205 @@ +using LanguageExt; +using Semver; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Dlls; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using DllRelease = UnchainedLauncher.Core.JsonModels.DllRelease; +using ManagedDllManager = UnchainedLauncher.Core.Services.Dlls.DllManager; + +namespace UnchainedLauncher.Core.Services.Mods; + +/// Resolves a CLI request, then uses the shared DLL manager for storage and injection. +public sealed class DllLaunchSession : IChivalry2LaunchPreparer, IProcessInjector { + private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromMinutes(5) }; + private readonly AggregateModRegistry _registry; + private readonly IModManager _mods; + private readonly string _directory; + private readonly string? _cacheDirectory; + private readonly bool _unsafe; + private readonly IReadOnlyList _requested; + private readonly CancellationToken _cancellationToken; + private readonly HttpClient _client; + private ManagedDllManager? _manager; + private bool _prepared; + private bool _server; + + public DllLaunchSession(AggregateModRegistry registry, IModManager mods, string directory, + IEnumerable? requested = null, bool unsafeLoadAllDlls = false, + CancellationToken cancellationToken = default, HttpClient? client = null, string? cacheDirectory = null) { + _registry = registry; + _mods = mods; + _directory = Path.GetFullPath(directory); + _cacheDirectory = cacheDirectory; + _requested = requested?.ToArray() ?? []; + _unsafe = unsafeLoadAllDlls; + _cancellationToken = cancellationToken; + _client = client ?? Client; + } + + public static bool Satisfies(DllRelease release, string requirement) => release.Version is { } version && SatisfiesVersion(version, requirement); + + public static bool SatisfiesVersion(SemVersion version, string requirement) => PluginInfo.MatchesVersion(version.ToString(), requirement); + + public static IReadOnlyList Resolve(IReadOnlyList packages, + IEnumerable requested, bool server, bool allowUnstable) { + var selected = new Dictionary(StringComparer.OrdinalIgnoreCase); + var roots = requested.ToList(); + if (!roots.Any(root => root.Org.Equals("Chiv2-Community", StringComparison.OrdinalIgnoreCase) + && root.ModuleName.Equals("UnchainedPlugin", StringComparison.OrdinalIgnoreCase))) + roots.Insert(0, new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", "latest")); + var pins = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var root in roots.Where(root => root.Version != "latest")) { + var id = root.Org + "/" + root.ModuleName; + if (pins.TryGetValue(id, out var pin) && pin != root.Version) + throw new InvalidOperationException($"Conflicting DLL requirements: {id}"); + pins[id] = root.Version; + } + if (!Search(roots.Select(root => (root.Org + "/" + root.ModuleName, root.Version, true, Array.Empty())).ToArray(), out var error)) + throw new InvalidOperationException(error); + var ordered = new List(); + var emitted = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var root in roots) Emit(root.Org + "/" + root.ModuleName); + return ordered; + + bool Search(IReadOnlyList<(string Id, string Requirement, bool Exact, string[] Path)> pending, out string? failure) { + failure = null; + if (pending.Count == 0) return true; + var (id, requirement, exact, path) = pending[0]; + if (path.Contains(id, StringComparer.OrdinalIgnoreCase)) { + failure = $"Cyclic DLL dependency: {id}"; + return false; + } + var package = packages.FirstOrDefault(p => (p.LatestReleaseInfo.Identifier.Org + "/" + p.LatestReleaseInfo.Identifier.ModuleName).Equals(id, StringComparison.OrdinalIgnoreCase)); + if (package == null) { + failure = $"DLL package not found: {id}"; + return false; + } + bool Accept(DllRelease r) => exact ? requirement == "latest" || r.Tag == requirement : Satisfies(r, requirement); + if (selected.TryGetValue(id, out var previous)) { + if (Accept(previous)) return Search(pending.Skip(1).ToArray(), out failure); + failure = $"Conflicting DLL requirements: {id}"; + return false; + } + var pinned = pins.TryGetValue(id, out var tag); + var candidates = package.Releases.Where(Accept).Where(release => !pinned || release.Tag == tag) + .Where(release => release.Info.Target == "shared" || release.Info.Target == (server ? "server" : "client")); + if (!pinned) candidates = candidates.Where(r => allowUnstable || r.Version?.IsRelease == true); + failure = $"DLL release not found for launch target: {id}:{requirement}"; + foreach (var release in candidates.OrderByDescending(r => r.Version).ThenByDescending(r => r.ReleaseDate)) { + selected.Add(id, release); + // Keep later roots in the same search so their constraints can reselect earlier dependencies. + List<(string Id, string Requirement, bool Exact, string[] Path)> next = []; + foreach (var dependency in release.Info.PluginDependencies) { + var dep = ModIdentifier.FromDependency(dependency); + next.Add((dep.Org + "/" + dep.ModuleName, dependency.Version, false, [.. path, id])); + } + next.AddRange(pending.Skip(1)); + if (Search(next, out failure)) return true; + selected.Remove(id); + } + return false; + } + + void Emit(string id) { + if (!emitted.Add(id)) return; + var release = selected[id]; + foreach (var dependency in release.Info.PluginDependencies) { + var dep = ModIdentifier.FromDependency(dependency); + Emit(dep.Org + "/" + dep.ModuleName); + } + ordered.Add(release); + } + } + + public async Task> PrepareLaunch(LaunchOptions options) { + _prepared = false; + _manager = null; + _cancellationToken.ThrowIfCancellationRequested(); + var catalog = await DllCatalog.ReadAsync(_registry, _cancellationToken); + if (catalog.Errors.Count != 0) throw new InvalidOperationException("DLL registry loading failed: " + string.Join("; ", catalog.Errors)); + _server = options.ServerLaunchOptions.IsSome; + var releases = Resolve(catalog.Packages, _requested.Concat(options.EnabledDlls), _server, options.AllowUnstablePluginReleases); + if (releases.Any(r => r.Info.ModDependencies.Count != 0)) { + var fetched = await _mods.UpdateModsList(); + if (fetched.HasErrors) throw new InvalidOperationException("Pak registry loading failed while resolving DLL dependencies."); + } + var selectedPaks = _mods.GetEnabledAndDependencyReleases().ToArray(); + var paksToEnable = new List(); + foreach (var requirements in releases.SelectMany(r => r.Info.ModDependencies) + .GroupBy(d => d.RepoUrl, StringComparer.OrdinalIgnoreCase)) { + var id = ModIdentifier.FromDependency(requirements.First()); + bool Accept(Release release) => release.Version is { } version && + requirements.All(dependency => SatisfiesVersion(version, dependency.Version)); + var existing = selectedPaks.Where(r => + r.Info.RepoUrl.Equals(requirements.Key, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (existing.Length != 0) { + if (existing.Any(r => !Accept(r))) + throw new InvalidOperationException("DLL pak dependency conflicts with selected mods."); + continue; + } + var mod = _mods.Mods.FirstOrDefault(m => m.LatestReleaseInfo.RepoUrl.Equals(requirements.Key, StringComparison.OrdinalIgnoreCase)); + var release = mod?.Releases.Where(Accept) + .OrderByDescending(r => r.Version).FirstOrDefault() + ?? throw new InvalidOperationException($"DLL pak dependency unavailable: {id.Org}/{id.ModuleName}"); + paksToEnable.Add(release); + } + foreach (var release in paksToEnable) { + if (!_mods.EnableModRelease(ReleaseCoordinates.FromRelease(release))) + throw new InvalidOperationException("Could not enable DLL pak dependency."); + } + var resolvedPaks = _mods.GetEnabledAndDependencyReleases().ToArray(); + if (resolvedPaks.GroupBy(r => r.Info.RepoUrl, StringComparer.OrdinalIgnoreCase).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) + throw new InvalidOperationException("Resolved pak dependencies contain conflicting versions."); + foreach (var release in releases) SafeFileName(release.DllFileName); + if (releases.Select(release => release.DllFileName).Distinct(StringComparer.OrdinalIgnoreCase).Count() != releases.Count) + throw new InvalidOperationException("DLL filename collision."); + _manager = ManagedDllManager.CreateForLaunch(_directory, + catalog.Packages.SelectMany(package => package.Releases).Select(release => release.ToManagedRelease()).ToArray(), + releases.Select(release => release.ToManagedRelease()).ToArray(), _client, + () => _mods.GetEnabledAndDependencyReleases().ToArray(), _cacheDirectory); + try { await _manager.PrepareAsync(_server, options.CheckForDependencyUpdates, _cancellationToken); } + catch (InvalidDataException ex) { throw new InvalidOperationException(ex.Message, ex); } + var files = _manager.OpenForInjection(_server); + string[] paths; + try { paths = files.Select(file => file.Path).ToArray(); } + finally { foreach (var file in files) file.Dispose(); } + var inventory = System.Text.Json.JsonSerializer.Serialize(releases.Select((release, i) => new { + Repository = release.Info.RepoUrl, + release.Tag, + release.Hash, + release.Size, + Path = paths[i] + })); + var inventoryPath = Path.Combine(_directory, "dll_manager.json"); + Directory.CreateDirectory(_directory); + var temporary = inventoryPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + await File.WriteAllTextAsync(temporary, inventory, _cancellationToken); + File.Move(temporary, inventoryPath, overwrite: true); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + _prepared = true; + return options; + } + + public static string SafeFileName(string name) { + if (string.IsNullOrWhiteSpace(name) || name.Contains('/') || name.Contains('\\') || name.Contains(':') + || name.IndexOfAny(['<', '>', '"', '|', '?', '*']) >= 0 || name.Any(char.IsControl) + || !name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Invalid DLL filename."); + return name; + } + + public static Task MatchesFileAsync(string path, DllRelease release) { + using var file = File.OpenRead(path); + try { ManagedDllManager.ValidateBytes(file, release.ToManagedRelease()); return Task.FromResult(true); } + catch (InvalidDataException) { return Task.FromResult(false); } + } + + public bool Inject(System.Diagnostics.Process process) => Inject(process, _server); + public bool Inject(System.Diagnostics.Process process, bool server) => _prepared && server == _server && _manager != null + && new DllInjector(_manager, _directory, _unsafe).Inject(process, server); +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/IModManager.cs b/UnchainedLauncher.Core/Services/Mods/IModManager.cs index 0ccd9d8a..c5edf500 100644 --- a/UnchainedLauncher.Core/Services/Mods/IModManager.cs +++ b/UnchainedLauncher.Core/Services/Mods/IModManager.cs @@ -346,7 +346,8 @@ public static async IAsyncEnumerable> InstallMods( coords, outputPath => modManager.ModRegistry .DownloadPak(coords, outputPath) - .MapLeft(e => Error.New(e)) + .MapLeft(e => Error.New(e)), + release.ReleaseHash ); }) .ToList(); diff --git a/UnchainedLauncher.Core/Services/Mods/IPakDir.cs b/UnchainedLauncher.Core/Services/Mods/IPakDir.cs index a407b030..769bfac1 100644 --- a/UnchainedLauncher.Core/Services/Mods/IPakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/IPakDir.cs @@ -9,14 +9,14 @@ namespace UnchainedLauncher.Core.Services.Mods { /// /// The coordinates identifying the specific release to install /// A factory function that creates a FileWriter for the given output path - public record ModInstallRequest(ReleaseCoordinates Coordinates, IPakDir.MakeFileWriter Writer); + public record ModInstallRequest(ReleaseCoordinates Coordinates, IPakDir.MakeFileWriter Writer, string? Hash = null); /// /// Represents a pak file that is managed by the PakDir system. /// /// The release coordinates identifying this pak /// The filename of the pak file within the pak directory - /// The load priority of this pak (lower values load first) + /// The patch priority of this pak (higher values override lower values) public record ManagedPak(ReleaseCoordinates Coordinates, string PakFileName, int Priority) { public override string ToString() => $"{Coordinates} @ {PakFileName} (priority {Priority})"; } diff --git a/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs b/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs new file mode 100644 index 00000000..d3e68e4c --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs @@ -0,0 +1,55 @@ +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Mods { + // Physical ownership is shared by hosts; enabled releases remain in their own configs. + internal sealed class ManagedPakInventory { + private readonly PakDir _pakDir; + private readonly string _path; + + private ManagedPakInventory(PakDir pakDir) { + _pakDir = pakDir; + _path = Path.Combine(pakDir.DirPath, FilePaths.ManagedPakInventoryFileName); + } + + public static ManagedPakInventory? Reconcile(IModManager manager) { + if (manager.PakDir is not PakDir pakDir) return null; + var inventory = new ManagedPakInventory(pakDir); + IEnumerable owned; + if (File.Exists(inventory._path)) { + // Once migrated, host snapshots must not resurrect obsolete ownership. + var decoded = JsonHelpers.Deserialize(File.ReadAllText(inventory._path)); + if (!decoded.Success) throw new InvalidDataException("Cannot read shared pak inventory.", decoded.Exception); + owned = decoded.Result!.ManagedPaks; + } + else { + var seeds = pakDir.ManagedPaks.ToList(); + // Existing hosts use installation-relative configs. Only migrate them for + // that installation, never for an unrelated/custom PakDir. + if (SameDirectory(pakDir.DirPath, FilePaths.PakDir)) { + var codec = new ModManagerCodec(manager.ModRegistry); + foreach (var path in new[] { FilePaths.ModManagerConfigPath, Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json") }) { + if (!File.Exists(path)) continue; + var decoded = codec.Deserialize(File.ReadAllText(path)); + if (!decoded.Success) throw new InvalidDataException($"Cannot read mod cache: {path}", decoded.Exception); + if (decoded.Result!.PakDir is PakDir cached && SameDirectory(cached.DirPath, pakDir.DirPath)) + seeds.AddRange(cached.ManagedPaks); + } + } + owned = seeds; + } + pakDir.ReconcileManagedPaks(owned); + return inventory; + } + + public void Save() { + var temporaryPath = _path + ".tmp"; + File.WriteAllText(temporaryPath, JsonHelpers.Serialize(new PakDirMetadata(_pakDir.DirPath, _pakDir.ManagedPaks, _pakDir.DefaultSigPath, _pakDir.CachePath))); + File.Move(temporaryPath, _path, overwrite: true); + } + + private static bool SameDirectory(string first, string second) => string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(first)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/ModManager.cs b/UnchainedLauncher.Core/Services/Mods/ModManager.cs index b03ebeee..a8b76ca4 100644 --- a/UnchainedLauncher.Core/Services/Mods/ModManager.cs +++ b/UnchainedLauncher.Core/Services/Mods/ModManager.cs @@ -52,6 +52,9 @@ public class ModManager : IModManager { public IPakDir PakDir { get; } + public ModManager WithPakDir(IPakDir directory) => + new(ModRegistry, directory, EnabledModReleaseCoordinates, Mods) { NeedsMetadataRefresh = NeedsMetadataRefresh }; + public ModManager( IModRegistry modRegistry, IPakDir pakDir, diff --git a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs index e4744ea6..ec3dc592 100644 --- a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs +++ b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs @@ -63,7 +63,7 @@ private static bool IsUsableCache(Mod mod) => private static PakDir CreatePakDir(PakDirMetadata? metadata) => metadata != null - ? new PakDir(metadata.DirPath, metadata.ManagedPaks) + ? new PakDir(metadata.DirPath, metadata.ManagedPaks, metadata.DefaultSigPath, metadata.CachePath) : new PakDir(FilePaths.PakDir, Enumerable.Empty()); public static ModManagerMetadata ToJsonType(IModManager manager) => @@ -72,7 +72,7 @@ public static ModManagerMetadata ToJsonType(IModManager manager) => m.EnabledModReleaseCoordinates, m.Mods, m.PakDir switch { - PakDir pd => new PakDirMetadata(pd.DirPath, pd.ManagedPaks), + PakDir pd => new PakDirMetadata(pd.DirPath, pd.ManagedPaks, pd.DefaultSigPath, pd.CachePath), _ => null } ), @@ -92,7 +92,11 @@ public record StandardModManagerMetadata( PakDirMetadata? PakDir ) : ModManagerMetadata(ModManagerMetadataKind.StandardModManager); - public record PakDirMetadata(string DirPath, IEnumerable ManagedPaks); + public record PakDirMetadata(string DirPath, IEnumerable ManagedPaks, + [property: System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + string? DefaultSigPath = null, + [property: System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + string? CachePath = null); internal static class ModManagerMetadataKind { public const string StandardModManager = "StandardModManager"; diff --git a/UnchainedLauncher.Core/Services/Mods/PakCache.cs b/UnchainedLauncher.Core/Services/Mods/PakCache.cs new file mode 100644 index 00000000..6f392cc4 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/PakCache.cs @@ -0,0 +1,64 @@ +using LanguageExt; +using LanguageExt.Common; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Mods; + +// Published objects are immutable. Updates replace directory entries, never linked bytes. +public sealed class PakCache(string root) { + public string Root { get; } = Path.GetFullPath(root); + + public EitherAsync Install(ModInstallRequest request, string destination, + Option> progress, CancellationToken cancellationToken = default) => TryAsync(async () => { + cancellationToken.ThrowIfCancellationRequested(); + var hash = request.Hash?.ToLowerInvariant(); + if (hash is null || !Regex.IsMatch(hash, "\\A[a-f0-9]{128}\\z")) + throw new InvalidDataException("A SHA-512 release hash is required for cached paks."); + var directory = Path.Combine(Root, hash); + Directory.CreateDirectory(directory); + using var lease = await AssetCacheFiles.LockAsync(Path.Combine(directory, ".lock"), cancellationToken); + var cached = Path.Combine(directory, "mod.pak"); + if (!File.Exists(cached)) { + var temporary = cached + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + var result = await request.Writer(temporary); + using var writer = result.Match(Right: value => value, Left: error => throw error.ToException()); + var written = await writer.WriteAsync(writer.Size > 0 ? progress : None, cancellationToken); + written.IfLeft(error => throw error.ToException()); + // Existing registry writers use zero when HTTP size discovery is unavailable. + if (writer.Size > 0 && new FileInfo(temporary).Length != writer.Size) + throw new InvalidDataException("Pak download size differs from the reported size."); + Validate(temporary, hash); + File.Move(temporary, cached); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + else Validate(cached, hash); + cancellationToken.ThrowIfCancellationRequested(); + // Creating a hard link is already atomic and does not replace an existing name. + // Renaming a staging link can fail on Windows while a peer has the pak open. + if (AssetCacheFiles.TryCreateHardLink(destination, cached)) { + progress.IfSome(p => p.Report(100)); + return Unit.Default; + } + var staging = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + File.Copy(cached, staging, overwrite: false); + // Never overwrite an unmanaged file that appeared during preparation. + File.Move(staging, destination); + } + finally { if (File.Exists(staging)) File.Delete(staging); } + progress.IfSome(p => p.Report(100)); + return Unit.Default; + }).ToEither(); + + private static void Validate(string path, string expected) { + using var stream = File.OpenRead(path); + if (!Convert.ToHexString(SHA512.HashData(stream)).Equals(expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Pak hash mismatch: {path}"); + } + +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index 2492daf3..3b3dce73 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -1,7 +1,6 @@ using LanguageExt; using LanguageExt.Common; using log4net; -using System.Text; using System.Text.RegularExpressions; using UnchainedLauncher.Core.Extensions; using UnchainedLauncher.Core.Services.Mods.Registry; @@ -12,22 +11,47 @@ namespace UnchainedLauncher.Core.Services.Mods { public class PakDir : IPakDir { public string DirPath { get; } + public string? DefaultSigPath { get; } + public string? CachePath { get; } + private readonly CancellationToken _cancellationToken; private const string BasePakFileName = "pakchunk0-WindowsNoEditor.pak"; private const string BaseSigFileName = "pakchunk0-WindowsNoEditor.sig"; - private const string ForcedSortDivider = "__-__"; private static readonly ILog Logger = LogManager.GetLogger(nameof(PakDir)); public IEnumerable ManagedPaks => _managedPaks; private readonly List _managedPaks; - public PakDir(string dirPath, IEnumerable managedPaks) { + public PakDir(string dirPath, IEnumerable managedPaks, string? defaultSigPath = null, string? cachePath = null, + CancellationToken cancellationToken = default) { DirPath = dirPath; + DefaultSigPath = defaultSigPath; + CachePath = cachePath; + _cancellationToken = cancellationToken; _managedPaks = managedPaks.ToList(); SynchronizeWithDir(); } + internal void ReconcileManagedPaks(IEnumerable managedPaks) { + var owned = managedPaks?.ToList(); + if (owned is null || owned.Any(p => p is null || p.PakFileName is null + || p.Coordinates is null || string.IsNullOrWhiteSpace(p.Coordinates.Org) + || string.IsNullOrWhiteSpace(p.Coordinates.ModuleName) + || string.IsNullOrWhiteSpace(p.Coordinates.Version))) + throw new InvalidDataException("Invalid managed pak inventory."); + // Inventory authorizes deletion only inside this directory, never base/user paks. + if (owned.Any(p => string.IsNullOrWhiteSpace(p.PakFileName) + || Path.GetFileName(p.PakFileName) != p.PakFileName + || p.PakFileName.Contains('\\') || p.PakFileName.Contains('/') + || !p.PakFileName.EndsWith(".pak", StringComparison.OrdinalIgnoreCase) + || p.PakFileName.Equals(BasePakFileName, StringComparison.OrdinalIgnoreCase))) + throw new InvalidDataException("Invalid managed pak filename in inventory."); + _managedPaks.Clear(); + _managedPaks.AddRange(owned.DistinctBy(p => p.PakFileName, StringComparer.OrdinalIgnoreCase)); + SynchronizeWithDir(); + } + private IEnumerable GetSigFiles() { return Directory.EnumerateFiles(DirPath, "*.sig"); } @@ -80,26 +104,38 @@ public Either Uninstall(ModIdentifier coords) => } ); + private static string PakSuffix(string fileName) { + var patch = Regex.Match(fileName, @"_[0-9]+_P\.pak\z", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return patch.Success ? patch.Value : Path.GetExtension(fileName); + } + + private static bool IsCollisionName(string fileName, string targetName) { + var suffix = PakSuffix(targetName); + return Regex.IsMatch(fileName, + @"\A" + Regex.Escape(targetName[..^suffix.Length]) + @" \([0-9]+\)" + Regex.Escape(suffix) + @"\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + private string AvailablePakName(string suggestedFileName) { var unManagedPaks = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); - var actualName = Path.GetFileNameWithoutExtension(suggestedFileName); - var extension = Path.GetExtension(suggestedFileName); + var signatures = GetSigFiles().ToHashSet(StringComparer.OrdinalIgnoreCase); + var extension = PakSuffix(suggestedFileName); + var actualName = suggestedFileName[..^extension.Length]; var fullActualName = actualName + extension; while (_managedPaks.Exists(x => x.PakFileName.Equals(fullActualName, StringComparison.OrdinalIgnoreCase)) - || unManagedPaks.Contains(PakNameToPakPath(fullActualName))) { + || unManagedPaks.Contains(PakNameToPakPath(fullActualName)) + || signatures.Contains(Path.ChangeExtension(PakNameToPakPath(fullActualName), ".sig"))) { actualName = Successors.TextualSuccessor(actualName); fullActualName = actualName + extension; } return fullActualName; } - private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter, Option> progress, string suggestedFileName) { - var fullActualName = AvailablePakName(suggestedFileName); + private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter, Option> progress, string fullActualName) { Logger.Info($"Downloading pak to {fullActualName}"); return mkFileWriter(PakNameToPakPath(fullActualName)) .Bind(fileWriter => - // TODO: allow passing of a cancellation token here - fileWriter.WriteAsync(progress, CancellationToken.None) + fileWriter.WriteAsync(progress, _cancellationToken) .Map(_ => fullActualName) ); } @@ -120,37 +156,36 @@ public async IAsyncEnumerable> InstallModSet( .ToHashSet(); var pakFileNames = installsList - .Select(install => { + .Select((install, order) => { var moduleName = install.Coordinates.ModuleName; var needsOrgPrefix = moduleNameCounts.Contains(moduleName); var baseName = needsOrgPrefix ? $"{install.Coordinates.Org}_{moduleName}" : moduleName; - return baseName + ".pak"; + // Unreal gives higher patch numbers priority over dependencies and vanilla. + return FormattableString.Invariant($"{baseName}_{order}_P.pak"); }) .ToList(); - // Apply sorted lexicographically to get the final names - var sortedPakNames = ApplySortedLexicographically(pakFileNames).ToList(); - // Build map of existing managed paks by coordinates (for mods in install set) var existingByCoords = _managedPaks .Filter(p => installCoordinatesSet.Contains(p.Coordinates)) - .ToDictionary(p => p.Coordinates, p => p); + .GroupBy(p => p.Coordinates) + .ToDictionary(g => g.Key, g => g.First()); var unmanagedPaths = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); + var signaturePaths = GetSigFiles().ToHashSet(StringComparer.OrdinalIgnoreCase); // Categorize each install: Skip (already correct), Move (relocate), or Download (new) var installActions = installsList - .Zip(sortedPakNames, (install, finalPakName) => (Install: install, FinalPakName: finalPakName)) + .Zip(pakFileNames, (install, finalPakName) => (Install: install, FinalPakName: finalPakName)) .Select((item, index) => { var existing = existingByCoords.TryGetValue(item.Install.Coordinates, out var e) ? Some(e) : None; return existing.Match( - // A collision-safe name is reusable only while its load-order prefix is correct. + // Reuse collision-safe names only while their module and numeric order match. Some: pak => pak.PakFileName.Equals(item.FinalPakName, StringComparison.OrdinalIgnoreCase) - || (unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) - && pak.PakFileName.StartsWith( - item.FinalPakName.Substring(0, item.FinalPakName.IndexOf(ForcedSortDivider, StringComparison.Ordinal) + ForcedSortDivider.Length), - StringComparison.OrdinalIgnoreCase)) + || ((unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) + || signaturePaths.Contains(Path.ChangeExtension(PakNameToPakPath(item.FinalPakName), ".sig"))) + && IsCollisionName(pak.PakFileName, item.FinalPakName)) ? new InstallAction.Skip(item.Install, index, pak) : new InstallAction.Move(item.Install, index, pak, item.FinalPakName) as InstallAction, None: () => new InstallAction.Download(item.Install, index, item.FinalPakName) @@ -161,7 +196,8 @@ public async IAsyncEnumerable> InstallModSet( // Categorize managed paks not in install set as Remove actions installActions.AddRange( _managedPaks - .Filter(p => !installCoordinatesSet.Any(c => c.Matches(p.Coordinates))) + .Filter(p => !installCoordinatesSet.Any(c => c.Matches(p.Coordinates)) + || (existingByCoords.TryGetValue(p.Coordinates, out var kept) && kept != p)) .Select(p => new InstallAction.Remove(p)) .ToList() ); @@ -204,7 +240,11 @@ public async IAsyncEnumerable> InstallModSet( // Process removes for (var i = 0; i < removeActions.Count; i++) { var removed = ProcessRemove(removeActions[i]); - if (removed.IsLeft) yield return removed.Map(_ => removeActions[i].Existing); + if (removed.IsLeft) { + // Do not reorder and lose ownership of an undeleted duplicate. + yield return removed.Map(_ => removeActions[i].Existing); + yield break; + } removeProgress.IfSome(p => p.Report(100d * (i + 1) / removeActions.Count)); } @@ -254,18 +294,23 @@ private Either ProcessMove(InstallAction.Move move) { var targetName = AvailablePakName(move.TargetPakName); var targetPath = PakNameToPakPath(targetName); - return _moveManagedPak(sourcePath, targetPath) - .Map(_ => { - var managedPak = new ManagedPak(move.Install.Coordinates, targetName, move.Index); - _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); - _managedPaks.Add(managedPak); - Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {targetName}"); - return managedPak; - }); + var managedPak = new ManagedPak(move.Install.Coordinates, targetName, move.Index); + return _moveManagedPak(sourcePath, targetPath, () => { + // Ownership follows the payload even if removing the old signature fails. + _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); + _managedPaks.Add(managedPak); + Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {targetName}"); + }) + .Map(_ => managedPak); } private async Task> ProcessDownload(InstallAction.Download download, Option taskProgress) { - return (await _writePak(download.Install.Writer, taskProgress.Map(p => (IProgress)p), download.FinalPakName)) + var name = AvailablePakName(download.FinalPakName); + var progress = taskProgress.Map(p => (IProgress)p); + var written = CachePath is null + ? await _writePak(download.Install.Writer, progress, name) + : (await new PakCache(CachePath).Install(download.Install, PakNameToPakPath(name), progress, _cancellationToken)).Map(_ => name); + return written .Map(pakFileName => { var managedPak = new ManagedPak(download.Install.Coordinates, pakFileName, download.Index); _managedPaks.Add(managedPak); @@ -277,8 +322,9 @@ private async Task> ProcessDownload(InstallAction.Down private Either ProcessRemove(InstallAction.Remove remove) { Logger.Info($"Removing pak: {remove.Existing.PakFileName} ({remove.Existing.Coordinates.ModuleName} version {remove.Existing.Coordinates.Version})"); - var uninstallResult = Uninstall(remove.Existing.Coordinates); - uninstallResult.IfRight(_ => _managedPaks.RemoveAll(p => p.Coordinates.Matches(remove.Existing.Coordinates))); + var path = PakNameToPakPath(remove.Existing.PakFileName); + var uninstallResult = _unsignFile(path).Bind(_ => _deleteFile(path)); + uninstallResult.IfRight(_ => _managedPaks.RemoveAll(p => p.PakFileName.Equals(remove.Existing.PakFileName, StringComparison.OrdinalIgnoreCase))); uninstallResult.IfLeft(e => Logger.Error($"Failed to remove pak {remove.Existing.PakFileName}: {e}")); return uninstallResult; } @@ -286,14 +332,24 @@ private Either ProcessRemove(InstallAction.Remove remove) { /// /// Moves a managed pak (and its .sig if present) from source to destination. /// - private static Either _moveManagedPak(string sourcePakPath, string destPakPath) { + private static Either _moveManagedPak(string sourcePakPath, string destPakPath, Action payloadMoved) { var sourceSigPath = Path.ChangeExtension(sourcePakPath, ".sig"); var destSigPath = Path.ChangeExtension(destPakPath, ".sig"); - return _moveFile(sourcePakPath, destPakPath) - .Bind(_ => File.Exists(sourceSigPath) - ? _moveFile(sourceSigPath, destSigPath) - : Right(Unit.Default)); + // Copy first: a failed payload move never requires rolling back its ownership + // or restoring the only signature. Never overwrite cache-linked file contents. + var hasSignature = File.Exists(sourceSigPath); + return (hasSignature ? _copyFile(sourceSigPath, destSigPath) : Right(Unit.Default)) + .Bind(_ => _moveFile(sourcePakPath, destPakPath).Match>( + Right: _ => { + payloadMoved(); + return hasSignature ? _deleteFile(sourceSigPath) : Right(Unit.Default); + }, + Left: error => hasSignature + ? _deleteFile(destSigPath).Match>( + Right: _ => Left(error), + Left: cleanupError => Left(Error.New($"{error}; signature cleanup also failed: {cleanupError}"))) + : Left(error))); } private static Either _moveFile(string source, string dest) { @@ -316,7 +372,7 @@ private Either _signFile(string path) { if (File.Exists(signedName)) { return Right(Unit.Default); } - var sigPath = Path.Join(DirPath, BaseSigFileName); + var sigPath = DefaultSigPath ?? Path.Join(DirPath, BaseSigFileName); return (File.Exists(sigPath) ? Either.Right(sigPath) : Error.New($"Default sig file '{sigPath}' not found")) @@ -393,41 +449,5 @@ public Either, Unit> DeleteOrphanedSigs() { .BindLefts(); } - /// - /// represents i using characters from alphabet - /// - /// - /// must be positive - /// - private static string RepUsingAlphabet(string alphabet, int i) { - var systemBase = alphabet.Length; - var sb = new StringBuilder(); - while (i > 0) { - sb.Append(alphabet[i % systemBase]); - i /= systemBase; - } - - return string.Join("", sb.ToString().Reverse()); - } - - /// - /// Given a sequence of strings, modify those strings such - /// that they are sorted lexicographically and return it - /// - /// - /// - private static IEnumerable ApplySortedLexicographically(IEnumerable inputs) { - var inputsList = inputs.ToList(); - - // We reverse the alphabet because we actually want to load dependencies backwards - var alphabet = new string("abcdefghijklmnopqrstuvwxyz".Reverse().ToArray()); - - var requiredSymbols = (int)Math.Ceiling(Math.Log(inputsList.Count, alphabet.Length)); - return inputsList.Map((i, n) => - $"q{RepUsingAlphabet(alphabet, i).PadLeft(requiredSymbols, alphabet[0])}" + - ForcedSortDivider + - $"{Regex.Replace(n, $".*{ForcedSortDivider}", "")}" - ); - } } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs new file mode 100644 index 00000000..0dff831a --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs @@ -0,0 +1,45 @@ +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Mods; + +public sealed class ProfileAssetPaths { + public const string SharedSuffix = "unchained"; + public string Suffix { get; } + public string Root { get; } + public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); + public string Plugins => Path.Combine(Root, "plugins"); + public string PakCache { get; } + public string DllCache { get; } + + public ProfileAssetPaths(string suffix, string? localAppData = null) { + var game = Path.Combine(localAppData ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Chivalry 2"); + var normalized = suffix.Replace("-", "", StringComparison.Ordinal); + if (normalized.Length == 0 || normalized.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_')) + throw new ArgumentException("Invalid saved directory suffix."); + Suffix = normalized; + Root = Path.Combine(game, "Saved_" + normalized, "unchained"); + PakCache = Path.Combine(game, "unchained", "cache", "paks"); + DllCache = Path.Combine(game, "unchained", "cache", "dlls"); + } + + public IDisposable AcquireLease() { + Directory.CreateDirectory(Root); + try { + return new FileStream(Path.Combine(Root, ".instance.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException ex) { + throw new IOException("Cannot lock the profile asset directory; another launcher may be using the same saved profile.", ex); + } + } + + public ModManager Rebind(ModManager manager, string installation) { + var sameProfile = Path.GetFullPath(manager.PakDir.DirPath).Equals(Path.GetFullPath(Paks), StringComparison.OrdinalIgnoreCase); + return manager.WithPakDir(CreatePakDir(installation, + sameProfile && manager.PakDir is PakDir existing ? existing.ManagedPaks : [])); + } + + public PakDir CreatePakDir(string installation, IEnumerable? managedPaks = null, CancellationToken cancellationToken = default) { + Directory.CreateDirectory(Paks); + return new PakDir(Paks, managedPaks ?? [], Path.Combine(installation, FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), PakCache, cancellationToken); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs index bfb24325..7ba39ec9 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs @@ -88,6 +88,17 @@ public EitherAsync GetModRelease(ReleaseCoor .Bind(maybeRelease => maybeRelease.ToEitherAsync(() => RegistryMetadataException.NotFound(coords, None))); + public async Task GetPakSize(Release release, CancellationToken cancellationToken = default) { + foreach (var registry in ModRegistries) { + cancellationToken.ThrowIfCancellationRequested(); + var matches = await registry.GetModRelease(ReleaseCoordinates.FromRelease(release)).Match( + candidate => candidate.ReleaseHash == release.ReleaseHash && candidate.PakFileName == release.PakFileName, + _ => false).WaitAsync(cancellationToken); + if (matches) return await registry.GetPakSize(release, cancellationToken); + } + return null; + } + public EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) { return InternalDownloadPak(coordinates, ModRegistries, None).ToAsync(); diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs b/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs new file mode 100644 index 00000000..935303bb --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.JsonModels; +using ManagedDllManager = UnchainedLauncher.Core.Services.Dlls.DllManager; +using ManagedDllRelease = UnchainedLauncher.Core.Services.Dlls.DllRelease; +using PluginInfo = UnchainedLauncher.Core.Services.Dlls.PluginInfo; + +namespace UnchainedLauncher.Core.Services.Mods.Registry; + +public sealed record DllCatalogResult(IReadOnlyList Packages, IReadOnlyList Errors); + +/// CLI query projection of the shared, validated DLL registry records. +public static class DllCatalog { + private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) }; + + public static async Task ReadAsync(AggregateModRegistry registry, CancellationToken cancellationToken) { + var releases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var errors = new List(); + foreach (var source in registry.ModRegistries) { + try { + if (source is GithubModRegistry github) { + foreach (var release in await ManagedDllManager.ReadRegistryAsync(Client, github.DllDatabaseUrl, cancellationToken)) Add(release); + } + else if (source is LocalModRegistry local) { + var root = Path.Combine(local.RegistryPath, "dll_db"); + var packages = Path.Combine(root, "packages"); + var index = Path.Combine(root, "plugin_list_index.txt"); + if (!Directory.Exists(packages) && !File.Exists(index)) continue; + var ids = File.Exists(index) + ? (await File.ReadAllLinesAsync(index, cancellationToken)).Select(line => line.Trim()).Where(line => line.Length != 0).ToArray() + : Directory.GetFiles(packages, "*.json", SearchOption.AllDirectories) + .Select(file => Path.GetRelativePath(packages, file)[..^5].Replace(Path.DirectorySeparatorChar, '/')).ToArray(); + if (ids.Distinct(StringComparer.OrdinalIgnoreCase).Count() != ids.Length) + throw new InvalidDataException("Duplicate plugin package identifier."); + foreach (var id in ids) { + try { + if (!Regex.IsMatch(id, "^[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$")) + throw new InvalidDataException("Invalid plugin package identifier."); + var path = Path.Combine(packages, id.Replace('/', Path.DirectorySeparatorChar) + ".json"); + foreach (var release in ManagedDllManager.ParsePackage(await File.ReadAllTextAsync(path, cancellationToken), id)) Add(release); + } + catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {id}: {ex.Message}"); } + } + } + else errors.Add($"DLL queries are not supported by registry: {source.Name}"); + } + catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {ex.Message}"); } + } + return new DllCatalogResult(releases.Values.GroupBy(release => release.Id, StringComparer.OrdinalIgnoreCase) + .Select(Project).OrderBy(package => package.LatestReleaseInfo.Identifier).ToArray(), errors); + + void Add(ManagedDllRelease release) { + if (releases.TryGetValue(release.Key, out var old) && ManagedDllManager.Canonical(old) != ManagedDllManager.Canonical(release)) + throw new InvalidDataException($"Conflicting DLL registry records: {release.Key}"); + releases[release.Key] = release; + } + } + + private static DllPackage Project(IEnumerable group) { + var records = group.ToArray(); + var id = records[0].Id; + var latest = records.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(release => release.Info != null)?.Info; + DllInfo Info(PluginInfo? info) => new() { + RepoUrl = "https://github.com/" + id, + Name = info?.Name ?? records[0].Name, + Description = info?.Description ?? "Metadata unavailable for this historical release.", + Authors = info?.Authors.ToList() ?? [], + Target = info?.Target ?? "shared", + IconUrl = info?.IconUrl, + ImageUrls = info?.ImageUrls?.ToList() ?? [], + PluginDependencies = info?.RequiredPlugins.ToList() ?? [], + ModDependencies = info?.RequiredMods.ToList() ?? [] + }; + return new DllPackage(Info(latest), records.Select(release => new DllRelease( + release.Tag ?? release.Version, release.Sha256, release.EffectiveFileName, release.DownloadUrl, + release.Size, release.ReleaseDate ?? DateTimeOffset.MinValue, Info(release.Info), release.ReleaseNotesMarkdown) { + RegisteredRelease = release + }).ToList()); + } + + private static bool IsReadFailure(Exception ex, CancellationToken token) => ex is HttpRequestException or IOException or InvalidDataException + or UnauthorizedAccessException or JsonException or ArgumentException or FormatException + || ex is OperationCanceledException && !token.IsCancellationRequested; +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs index 510c9715..df2fce92 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs @@ -9,6 +9,7 @@ namespace UnchainedLauncher.Core.Services.Mods.Registry { public class GithubModRegistry : JsonRegistry, IModRegistry { private static new readonly ILog Logger = LogManager.GetLogger(typeof(GithubModRegistry)); + private static readonly HttpClient SizeClient = new() { Timeout = TimeSpan.FromSeconds(15) }; public override string Name => $"Github mod registry at {Organization}/{RepoName}"; public string Organization { get; set; } @@ -86,5 +87,15 @@ private EitherAsync GetGithubPakStr private string GetGithubPakDownloadUrl(Release release) => $"https://github.com/{release.Info.Organization}/{release.Info.RepoName}/releases/download/{release.Tag}/{release.PakFileName}"; + + public override async Task GetPakSize(Release release, CancellationToken cancellationToken = default) { + try { + using var request = new HttpRequestMessage(HttpMethod.Head, GetGithubPakDownloadUrl(release)); + using var response = await SizeClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.IsSuccessStatusCode ? response.Content.Headers.ContentLength : null; + } + catch (HttpRequestException) { return null; } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return null; } + } } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs index ebaa33f6..b01a265a 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs @@ -1,147 +1,151 @@ -using LanguageExt; -using LanguageExt.Common; -using log4net; -using Semver; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Utilities; -using static LanguageExt.Prelude; - -namespace UnchainedLauncher.Core.Services.Mods.Registry { - - public record ModIdentifier(string Org, string ModuleName) : IComparable { - public static ModIdentifier FromMod(Mod mod) => - new ModIdentifier(mod.LatestReleaseInfo.Organization, mod.LatestReleaseInfo.RepoName); - - public static ModIdentifier FromDependency(Dependency dependency) => new ModIdentifier(dependency.Organization, dependency.RepoName); - public static ModIdentifier FromRelease(Release release) => new ModIdentifier(release.Info.Organization, release.Info.RepoName); - - public bool Matches(Mod mod) => Org == mod.LatestReleaseInfo.Organization && ModuleName == mod.LatestReleaseInfo.RepoName; - - // Intentionally not using equality of this == modId here, because the other ModIdentifier may be a ReleaseCoordinate. - public bool Matches(ModIdentifier modId) => Org == modId.Org && ModuleName == modId.ModuleName; - public int CompareTo(ModIdentifier? other) => - other == null - ? -1 - : (this.Org, this.ModuleName).CompareTo((other.Org, other.ModuleName)); - - public override string ToString() { - return $"('{Org}','{ModuleName}')"; - } - } - - public record ReleaseCoordinates(string Org, string ModuleName, string Version) : ModIdentifier(Org, ModuleName), IComparable { - public static new ReleaseCoordinates FromRelease(Release release) => - new ReleaseCoordinates(release.Info.Organization, release.Info.RepoName, release.Tag); - - public bool Matches(Release release) => Org == release.Info.Organization && ModuleName == release.Info.RepoName && Version == release.Tag; - public int CompareTo(ReleaseCoordinates? other) { - if (other == null) return -1; - - SemVersion.TryParse(Version, SemVersionStyles.Any, out var thisVersion); - SemVersion.TryParse(other.Version, SemVersionStyles.Any, out var otherVersion); - - return (thisVersion, otherVersion) switch { - (null, null) => - (this.Org, this.ModuleName, this.Version).CompareTo((other.Org, other.ModuleName, other.Version)), - (null, _) => - (this.Org, this.ModuleName, new SemVersion(0, 0, 0)).CompareTo((other.Org, other.ModuleName, - otherVersion)), - (_, _) => - (this.Org, this.ModuleName, thisVersion).CompareTo((other.Org, other.ModuleName, otherVersion!)), - }; - } - - public override string ToString() { - return $"('{Org}','{ModuleName}', '{Version}')"; - } - } - - public record GetAllModsResult(IEnumerable Errors, IEnumerable Mods) { - public bool HasErrors => Errors.Any(); - - public GetAllModsResult AddError(RegistryMetadataException error) { - return this with { Errors = Errors.Append(error) }; - } - - public GetAllModsResult AddMod(Mod mod) { - return this with { Mods = Mods.Append(mod) }; - } - - public static GetAllModsResult operator +(GetAllModsResult a, GetAllModsResult b) { - return new GetAllModsResult(a.Errors.Concat(b.Errors), a.Mods.Concat(b.Mods)); - } - }; - - /// - /// A ModRegistry contains all relevant metadata for a repository of mods - /// A ModRegistry does not necessarily know how to download the mods, however - /// it will provide the coordinates which can be used to download the mods via - /// ModRegistryPakFetcher - /// - public interface IModRegistry { - protected static readonly ILog Logger = LogManager.GetLogger(nameof(IModRegistry)); - - public string Name { get; } - - /// - /// Get all mod metadata from this registry - /// - /// - public Task GetAllMods(); - - /// - /// Fetches a mod object from the registry - /// - /// - /// - public EitherAsync GetMod(ModIdentifier modId); - - /// - /// Fetches a specific release from the registry - /// - /// - /// - public EitherAsync GetModRelease(ReleaseCoordinates coords); - - /// - /// Download pak - /// - /// - /// - /// - public EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation); - } - - public record SizedStream(Stream Stream, long Size); - public record ModPakStreamAcquisitionFailure(ReleaseCoordinates Target, Error Error) : Expected($"Failed to acquire download stream for mod pak '{Target.Org} / {Target.ModuleName} / {Target.Version}", 4000, Some(Error)); - - public abstract record RegistryMetadataException(string Message, int Code, Option Underlying) : Expected(Message, Code, Underlying) { - public record ParseException(string Message, Option Underlying) - : RegistryMetadataException(Message, 0, Underlying); - - public record NotFoundException(ModIdentifier ModId, Option Version, Option Underlying) - : RegistryMetadataException(FormatMessage(ModId, Version), 0, Underlying) { - private static string FormatMessage(ModIdentifier modId, Option version) => - $"Failed to get mod metadata for '{modId.Org}/{modId.ModuleName}" + - version.Map(v => $" / {v}'").IfNone("'"); - }; - public record PackageListRetrievalException(string Message, Option Underlying) : RegistryMetadataException(Message, 0, Underlying); - - public static RegistryMetadataException Parse(string message, Option underlying) => new ParseException(message, underlying); - public static RegistryMetadataException NotFound(ModIdentifier modId, Option underlying) => new NotFoundException(modId, None, underlying); - public static RegistryMetadataException NotFound(ReleaseCoordinates coords, Option underlying) => - new NotFoundException(coords, Some(coords.Version), underlying); - public static RegistryMetadataException PackageListRetrieval(string message, Option underlying) => new PackageListRetrievalException(message, underlying); - - public T Match( - Func parseExceptionFunc, - Func notFoundFunc, - Func packageListRetrievalFunc - ) => this switch { - NotFoundException notFound => notFoundFunc(notFound), - PackageListRetrievalException packageListRetrievalException => packageListRetrievalFunc(packageListRetrievalException), - ParseException parseError => parseExceptionFunc(parseError), - _ => throw new ArgumentOutOfRangeException() - }; - } +using LanguageExt; +using LanguageExt.Common; +using log4net; +using Semver; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Mods.Registry { + + public record ModIdentifier(string Org, string ModuleName) : IComparable { + public static ModIdentifier FromMod(Mod mod) => + new ModIdentifier(mod.LatestReleaseInfo.Organization, mod.LatestReleaseInfo.RepoName); + + public static ModIdentifier FromDependency(Dependency dependency) => new ModIdentifier(dependency.Organization, dependency.RepoName); + public static ModIdentifier FromRelease(Release release) => new ModIdentifier(release.Info.Organization, release.Info.RepoName); + + public bool Matches(Mod mod) => Org == mod.LatestReleaseInfo.Organization && ModuleName == mod.LatestReleaseInfo.RepoName; + + // Intentionally not using equality of this == modId here, because the other ModIdentifier may be a ReleaseCoordinate. + public bool Matches(ModIdentifier modId) => Org == modId.Org && ModuleName == modId.ModuleName; + public int CompareTo(ModIdentifier? other) => + other == null + ? -1 + : (this.Org, this.ModuleName).CompareTo((other.Org, other.ModuleName)); + + public override string ToString() { + return $"('{Org}','{ModuleName}')"; + } + } + + public record ReleaseCoordinates(string Org, string ModuleName, string Version) : ModIdentifier(Org, ModuleName), IComparable { + public static new ReleaseCoordinates FromRelease(Release release) => + new ReleaseCoordinates(release.Info.Organization, release.Info.RepoName, release.Tag); + + public bool Matches(Release release) => Org == release.Info.Organization && ModuleName == release.Info.RepoName && Version == release.Tag; + public int CompareTo(ReleaseCoordinates? other) { + if (other == null) return -1; + + SemVersion.TryParse(Version, SemVersionStyles.Any, out var thisVersion); + SemVersion.TryParse(other.Version, SemVersionStyles.Any, out var otherVersion); + + return (thisVersion, otherVersion) switch { + (null, null) => + (this.Org, this.ModuleName, this.Version).CompareTo((other.Org, other.ModuleName, other.Version)), + (null, _) => + (this.Org, this.ModuleName, new SemVersion(0, 0, 0)).CompareTo((other.Org, other.ModuleName, + otherVersion)), + (_, _) => + (this.Org, this.ModuleName, thisVersion).CompareTo((other.Org, other.ModuleName, otherVersion!)), + }; + } + + public override string ToString() { + return $"('{Org}','{ModuleName}', '{Version}')"; + } + } + + public record GetAllModsResult(IEnumerable Errors, IEnumerable Mods) { + public bool HasErrors => Errors.Any(); + + public GetAllModsResult AddError(RegistryMetadataException error) { + return this with { Errors = Errors.Append(error) }; + } + + public GetAllModsResult AddMod(Mod mod) { + return this with { Mods = Mods.Append(mod) }; + } + + public static GetAllModsResult operator +(GetAllModsResult a, GetAllModsResult b) { + return new GetAllModsResult(a.Errors.Concat(b.Errors), a.Mods.Concat(b.Mods)); + } + }; + + /// + /// A ModRegistry contains all relevant metadata for a repository of mods + /// A ModRegistry does not necessarily know how to download the mods, however + /// it will provide the coordinates which can be used to download the mods via + /// ModRegistryPakFetcher + /// + public interface IModRegistry { + protected static readonly ILog Logger = LogManager.GetLogger(nameof(IModRegistry)); + + public string Name { get; } + + /// + /// Get all mod metadata from this registry + /// + /// + public Task GetAllMods(); + + /// + /// Fetches a mod object from the registry + /// + /// + /// + public EitherAsync GetMod(ModIdentifier modId); + + /// + /// Fetches a specific release from the registry + /// + /// + /// + public EitherAsync GetModRelease(ReleaseCoordinates coords); + + /// Returns pak length without downloading the pak, or null if unavailable. + public Task GetPakSize(Release release, CancellationToken cancellationToken = default) => Task.FromResult(null); + + /// + /// Acquires a single-use pak writer that owns the registry input stream. + /// The caller must execute WriteAsync or dispose the returned writer. + /// + /// + /// + /// + public EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation); + } + + public record SizedStream(Stream Stream, long Size); + public record ModPakStreamAcquisitionFailure(ReleaseCoordinates Target, Error Error) : Expected($"Failed to acquire download stream for mod pak '{Target.Org} / {Target.ModuleName} / {Target.Version}", 4000, Some(Error)); + + public abstract record RegistryMetadataException(string Message, int Code, Option Underlying) : Expected(Message, Code, Underlying) { + public record ParseException(string Message, Option Underlying) + : RegistryMetadataException(Message, 0, Underlying); + + public record NotFoundException(ModIdentifier ModId, Option Version, Option Underlying) + : RegistryMetadataException(FormatMessage(ModId, Version), 0, Underlying) { + private static string FormatMessage(ModIdentifier modId, Option version) => + $"Failed to get mod metadata for '{modId.Org}/{modId.ModuleName}" + + version.Map(v => $" / {v}'").IfNone("'"); + }; + public record PackageListRetrievalException(string Message, Option Underlying) : RegistryMetadataException(Message, 0, Underlying); + + public static RegistryMetadataException Parse(string message, Option underlying) => new ParseException(message, underlying); + public static RegistryMetadataException NotFound(ModIdentifier modId, Option underlying) => new NotFoundException(modId, None, underlying); + public static RegistryMetadataException NotFound(ReleaseCoordinates coords, Option underlying) => + new NotFoundException(coords, Some(coords.Version), underlying); + public static RegistryMetadataException PackageListRetrieval(string message, Option underlying) => new PackageListRetrievalException(message, underlying); + + public T Match( + Func parseExceptionFunc, + Func notFoundFunc, + Func packageListRetrievalFunc + ) => this switch { + NotFoundException notFound => notFoundFunc(notFound), + PackageListRetrievalException packageListRetrievalException => packageListRetrievalFunc(packageListRetrievalException), + ParseException parseError => parseExceptionFunc(parseError), + _ => throw new ArgumentOutOfRangeException() + }; + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs index 9f11887b..f65cdb00 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs @@ -14,6 +14,7 @@ public abstract class JsonRegistry : IModRegistry { public abstract EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation); public abstract Task GetAllMods(); + public virtual Task GetPakSize(Release release, CancellationToken cancellationToken = default) => Task.FromResult(null); /// /// Gets the mod metadata in a string format. diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/LocalModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/LocalModRegistry.cs index edab6e26..d30a4649 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/LocalModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/LocalModRegistry.cs @@ -1,203 +1,212 @@ -using LanguageExt; -using LanguageExt.Common; -using LanguageExt.UnsafeValueAccess; -using System.Collections.Immutable; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Utilities; -using static LanguageExt.Prelude; - -namespace UnchainedLauncher.Core.Services.Mods.Registry { - public class LocalModRegistry : JsonRegistry { - public override string Name => $"Local filesystem registry at {RegistryPath}"; - - private string _registryPathInternal; - - public string RegistryPath { - get => _registryPathInternal; - set { _registryPathInternal = !Path.IsPathRooted(value) ? Path.GetFullPath(value) : value; } - } - - public LocalModRegistry(string registryPath) { - if (registryPath == "") - registryPath = "LocalModRegistry"; - - _registryPathInternal = ""; // Technically does nothing, but cleans up a warning. - RegistryPath = registryPath; - - if (!Directory.Exists(RegistryPath)) Directory.CreateDirectory(RegistryPath); - } - - public event Action? OnRegistryChanged; - - public override EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) { - return GetMod(coordinates) - .Map(releaseMetadata => Optional(releaseMetadata.Releases.Find(x => x.Tag == coordinates.Version))) - .MapLeft(e => new ModPakStreamAcquisitionFailure(coordinates, e)) - .Bind(maybeRelease => maybeRelease.ToEitherAsync(() => - new ModPakStreamAcquisitionFailure( - coordinates, - Error.New($"Failed to fetch pak. No releases found for {coordinates.Org} / {coordinates.ModuleName} / {coordinates.Version}.") - ))) - .Bind(ModPakStream) - .Map(sizedStream => new FileWriter(outputLocation, sizedStream.Stream, sizedStream.Size)); - } - - public async Task DeleteRelease(ReleaseCoordinates coordinates) { - return await GetMod(coordinates) - .Bind(mod => GetModRelease(coordinates) - .Map(release => (mod, release))) - .MatchAsync( - async result => { - var mod = result.mod!; - var toDelete = result.release!; - - var modPath = Path.Combine(RegistryPath, coordinates.Org, coordinates.ModuleName); - var releasePath = Path.Combine(modPath, toDelete.Tag); - mod.Releases.Remove(toDelete); - - if (mod.Releases.Count == 0) { - Directory.Delete(modPath, true); - } - else { - Directory.Delete(releasePath, true); - await WriteMod(mod with { - // we know this unwrap is safe because if there were no releases, we would have deleted - LatestReleaseInfo = mod.LatestRelease.ValueUnsafe().Info - }); - } - - OnRegistryChanged?.Invoke(coordinates); - return true; - }, - _ => { - Logger.Warn($"Failed to delete release {coordinates.Org}/{coordinates.ModuleName}/{coordinates.Version}. Mod or specific release not found."); - return false; - } - ); - } - - public async Task AddRelease(Release newVersion, string pakPath) { - ReleaseCoordinates coordinates = ReleaseCoordinates.FromRelease(newVersion); - string modDirectory = Path.Combine(RegistryPath, coordinates.Org, coordinates.ModuleName); - string modJsonPath = Path.Combine(modDirectory, $"{coordinates.ModuleName}.json"); - var mod = await InternalGetModMetadata(modJsonPath) - .Match( - existing => new Mod( - newVersion.Info, - existing.Releases - .Filter(x => !ReleaseCoordinates.FromRelease(x).Matches(newVersion)) - .Append(newVersion).ToList() - ), - _ => new Mod( - newVersion.Info, - new List { newVersion } - ) - ); - - await CopyFileForRelease(newVersion, pakPath); - await WriteMod(mod); - OnRegistryChanged?.Invoke(coordinates); - } - - private Task CopyFileForRelease(Release release, string sourceFile) { - return Task.Run(() => { - string releaseDirectory = Path.Combine( - RegistryPath, - release.Info.Organization, - release.Info.Name, - release.Tag - ); - - string releasePakPath = Path.Combine(releaseDirectory, release.PakFileName); - Directory.CreateDirectory(releaseDirectory); - if (sourceFile == releasePakPath) return; - File.Copy(sourceFile, releasePakPath, true); - }); - } - - private async Task WriteMod(Mod modToWrite) { - ModIdentifier ident = ModIdentifier.FromMod(modToWrite); - string modDirectory = Path.Combine(RegistryPath, ident.Org, ident.ModuleName); - string manifestPath = Path.Combine(modDirectory, $"{ident.ModuleName}.json"); - Directory.CreateDirectory(modDirectory); - await File.WriteAllTextAsync(manifestPath, JsonHelpers.Serialize(modToWrite)); - } - - private EitherAsync InternalGetModMetadata(string jsonManifestPath) { - var dir = Path.GetDirectoryName(jsonManifestPath); - if (dir == null) { - return - LeftAsync(RegistryMetadataException.Parse($"Failed to parse json manifest path {jsonManifestPath}. Got null directory name.", None)); - } - - var parts = dir.Split(Path.DirectorySeparatorChar); - if (parts.Length() < 2) return LeftAsync(RegistryMetadataException.PackageListRetrieval($"Failed to determine module id for file at path {jsonManifestPath}", None)); - var modIdParts = parts.Reverse().Take(2).Reverse(); - - return GetMod(new ModIdentifier(modIdParts.First(), modIdParts.Last())); - } - - public override Task GetAllMods() { - return Task - .Run(() => Directory.EnumerateFiles(RegistryPath, "*.json", SearchOption.AllDirectories)) - .Bind(paths => - paths.ToImmutableList() - .Select(InternalGetModMetadata) - .Partition() - .Select(t => new GetAllModsResult(t.Lefts, t.Rights)) - ); - } - - protected override EitherAsync GetModMetadataString(ModIdentifier modId) { - return EitherAsync - .Right(Path.Combine(RegistryPath, modId.Org, modId.ModuleName)) - .BindAsync(path => Task.Run(() => { - if (!Directory.Exists(path)) - return EitherAsync - .Left(RegistryMetadataException.NotFound(modId, Some(Error.New(new IOException("File not found"))))); - - var manifests = Directory.EnumerateFiles(path, "*.json", SearchOption.TopDirectoryOnly); - - switch (manifests.Count()) { - case > 1: - Logger.Warn($"Found multiple candidates for manifests at {path}. Using the first one."); - break; - case 0: - return LeftAsync(RegistryMetadataException.NotFound(modId, Some(Error.New("No manifests")))); - } - - var manifest = manifests.Single(); - - return Prelude - .TryAsync(Task.Run(() => File.ReadAllText(manifest))) - .ToEither() - .MapLeft(e => RegistryMetadataException.NotFound(modId, e)); - })); - } - - private EitherAsync ModPakStream(Release release) { - // Paks will be found in PakReleasesDir/org/repoName/releaseTag/fileName - var path = Path.Combine(RegistryPath, release.Info.Organization, release.Info.RepoName, release.Tag, release.PakFileName); - - if (!File.Exists(path)) - return - LeftAsync( - new ModPakStreamAcquisitionFailure( - ReleaseCoordinates.FromRelease(release), - Error.New($"Failed to fetch pak. File not found: {path}") - ) - ); - - return - TryAsync(Task.Run(() => File.OpenRead(path))) - .Map(stream => new SizedStream(stream, stream.Length)) - .ToEither() - .MapLeft(e => - new ModPakStreamAcquisitionFailure( - ReleaseCoordinates.FromRelease(release), - Error.New($"Failed to fetch pak from {path}.", e) - ) - ); - } - } +using LanguageExt; +using LanguageExt.Common; +using LanguageExt.UnsafeValueAccess; +using System.Collections.Immutable; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Mods.Registry { + public class LocalModRegistry : JsonRegistry { + public override string Name => $"Local filesystem registry at {RegistryPath}"; + + private string _registryPathInternal; + + public string RegistryPath { + get => _registryPathInternal; + set { _registryPathInternal = !Path.IsPathRooted(value) ? Path.GetFullPath(value) : value; } + } + + public LocalModRegistry(string registryPath) { + if (registryPath == "") + registryPath = "LocalModRegistry"; + + _registryPathInternal = ""; // Technically does nothing, but cleans up a warning. + RegistryPath = registryPath; + + if (!Directory.Exists(RegistryPath)) Directory.CreateDirectory(RegistryPath); + } + + public event Action? OnRegistryChanged; + + public override EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) { + return GetMod(coordinates) + .Map(releaseMetadata => Optional(releaseMetadata.Releases.Find(x => x.Tag == coordinates.Version))) + .MapLeft(e => new ModPakStreamAcquisitionFailure(coordinates, e)) + .Bind(maybeRelease => maybeRelease.ToEitherAsync(() => + new ModPakStreamAcquisitionFailure( + coordinates, + Error.New($"Failed to fetch pak. No releases found for {coordinates.Org} / {coordinates.ModuleName} / {coordinates.Version}.") + ))) + .Bind(ModPakStream) + .Map(sizedStream => new FileWriter(outputLocation, sizedStream.Stream, sizedStream.Size)); + } + + public async Task DeleteRelease(ReleaseCoordinates coordinates) { + return await GetMod(coordinates) + .Bind(mod => GetModRelease(coordinates) + .Map(release => (mod, release))) + .MatchAsync( + async result => { + var mod = result.mod!; + var toDelete = result.release!; + + var modPath = Path.Combine(RegistryPath, coordinates.Org, coordinates.ModuleName); + var releasePath = Path.Combine(modPath, toDelete.Tag); + mod.Releases.Remove(toDelete); + + if (mod.Releases.Count == 0) { + Directory.Delete(modPath, true); + } + else { + Directory.Delete(releasePath, true); + await WriteMod(mod with { + // we know this unwrap is safe because if there were no releases, we would have deleted + LatestReleaseInfo = mod.LatestRelease.ValueUnsafe().Info + }); + } + + OnRegistryChanged?.Invoke(coordinates); + return true; + }, + _ => { + Logger.Warn($"Failed to delete release {coordinates.Org}/{coordinates.ModuleName}/{coordinates.Version}. Mod or specific release not found."); + return false; + } + ); + } + + public async Task AddRelease(Release newVersion, string pakPath) { + ReleaseCoordinates coordinates = ReleaseCoordinates.FromRelease(newVersion); + string modDirectory = Path.Combine(RegistryPath, coordinates.Org, coordinates.ModuleName); + string modJsonPath = Path.Combine(modDirectory, $"{coordinates.ModuleName}.json"); + var mod = await InternalGetModMetadata(modJsonPath) + .Match( + existing => new Mod( + newVersion.Info, + existing.Releases + .Filter(x => !ReleaseCoordinates.FromRelease(x).Matches(newVersion)) + .Append(newVersion).ToList() + ), + _ => new Mod( + newVersion.Info, + new List { newVersion } + ) + ); + + await CopyFileForRelease(newVersion, pakPath); + await WriteMod(mod); + OnRegistryChanged?.Invoke(coordinates); + } + + private Task CopyFileForRelease(Release release, string sourceFile) { + return Task.Run(() => { + string releaseDirectory = Path.Combine( + RegistryPath, + release.Info.Organization, + release.Info.Name, + release.Tag + ); + + string releasePakPath = Path.Combine(releaseDirectory, release.PakFileName); + Directory.CreateDirectory(releaseDirectory); + if (sourceFile == releasePakPath) return; + File.Copy(sourceFile, releasePakPath, true); + }); + } + + private async Task WriteMod(Mod modToWrite) { + ModIdentifier ident = ModIdentifier.FromMod(modToWrite); + string modDirectory = Path.Combine(RegistryPath, ident.Org, ident.ModuleName); + string manifestPath = Path.Combine(modDirectory, $"{ident.ModuleName}.json"); + Directory.CreateDirectory(modDirectory); + await File.WriteAllTextAsync(manifestPath, JsonHelpers.Serialize(modToWrite)); + } + + private EitherAsync InternalGetModMetadata(string jsonManifestPath) { + var dir = Path.GetDirectoryName(jsonManifestPath); + if (dir == null) { + return + LeftAsync(RegistryMetadataException.Parse($"Failed to parse json manifest path {jsonManifestPath}. Got null directory name.", None)); + } + + var parts = dir.Split(Path.DirectorySeparatorChar); + if (parts.Length() < 2) return LeftAsync(RegistryMetadataException.PackageListRetrieval($"Failed to determine module id for file at path {jsonManifestPath}", None)); + var modIdParts = parts.Reverse().Take(2).Reverse(); + + return GetMod(new ModIdentifier(modIdParts.First(), modIdParts.Last())); + } + + public override Task GetAllMods() { + return Task + .Run(() => Directory.EnumerateFiles(RegistryPath, "*.json", SearchOption.AllDirectories) + .Where(path => !Path.GetRelativePath(RegistryPath, path).StartsWith("dll_db" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) + .Bind(paths => + paths.ToImmutableList() + .Select(InternalGetModMetadata) + .Partition() + .Select(t => new GetAllModsResult(t.Lefts, t.Rights)) + ); + } + + protected override EitherAsync GetModMetadataString(ModIdentifier modId) { + return EitherAsync + .Right(Path.Combine(RegistryPath, modId.Org, modId.ModuleName)) + .BindAsync(path => Task.Run(() => { + if (!Directory.Exists(path)) + return EitherAsync + .Left(RegistryMetadataException.NotFound(modId, Some(Error.New(new IOException("File not found"))))); + + var manifests = Directory.EnumerateFiles(path, "*.json", SearchOption.TopDirectoryOnly); + + switch (manifests.Count()) { + case > 1: + Logger.Warn($"Found multiple candidates for manifests at {path}. Using the first one."); + break; + case 0: + return LeftAsync(RegistryMetadataException.NotFound(modId, Some(Error.New("No manifests")))); + } + + var manifest = manifests.Single(); + + return Prelude + .TryAsync(Task.Run(() => File.ReadAllText(manifest))) + .ToEither() + .MapLeft(e => RegistryMetadataException.NotFound(modId, e)); + })); + } + + private EitherAsync ModPakStream(Release release) { + // Paks will be found in PakReleasesDir/org/repoName/releaseTag/fileName + var path = GetPakPath(release); + + if (!File.Exists(path)) + return + LeftAsync( + new ModPakStreamAcquisitionFailure( + ReleaseCoordinates.FromRelease(release), + Error.New($"Failed to fetch pak. File not found: {path}") + ) + ); + + return + TryAsync(Task.Run(() => File.OpenRead(path))) + .Map(stream => new SizedStream(stream, stream.Length)) + .ToEither() + .MapLeft(e => + new ModPakStreamAcquisitionFailure( + ReleaseCoordinates.FromRelease(release), + Error.New($"Failed to fetch pak from {path}.", e) + ) + ); + } + + private string GetPakPath(Release release) => Path.Combine(RegistryPath, release.Info.Organization, release.Info.RepoName, release.Tag, release.PakFileName); + + public override Task GetPakSize(Release release, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + try { return Task.FromResult(new FileInfo(GetPakPath(release)).Length); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return Task.FromResult(null); } + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs index 618747f0..1bb2f456 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs @@ -1,181 +1,186 @@ -using LanguageExt; -using LanguageExt.Common; -using System.Diagnostics; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Utilities; - -namespace UnchainedLauncher.Core.Services.Processes.Chivalry; - -using static LanguageExt.Prelude; -public interface IChivalry2Launcher { - /// - /// Launches a vanilla game. Implementations may do extra work to enable client side pak file loading - /// - /// - /// - /// Left if the game failed to launch. - /// Right if the game was launched successfully. - /// - Task> Launch(LaunchOptions options); -} - -public abstract record UnchainedLaunchFailure(string Message, int Code, Option Underlying) : Expected(Message, Code, Underlying) { - public record InjectionFailedError() : UnchainedLaunchFailure("Failed to inject process with unchained code.", 0, None); - public record LaunchFailedError(LaunchFailed Failure) : UnchainedLaunchFailure(Failure.Message, 0, Failure.Underlying); - public record DependencyDownloadFailedError(IEnumerable DependencyNames) - : UnchainedLaunchFailure($"Failed to download dependencies '{string.Join("', '", DependencyNames)}'", 0, None); - public record LaunchCancelledError() : UnchainedLaunchFailure($"Launch Cancelled", 0, None); - public record MissingArgumentsError() : UnchainedLaunchFailure("Necessary modded launch arguments not provided.", 0, None); - - - public static UnchainedLaunchFailure InjectionFailed() => - new InjectionFailedError(); - public static UnchainedLaunchFailure LaunchFailed(LaunchFailed failure) => new LaunchFailedError(failure); - public static UnchainedLaunchFailure DependencyDownloadFailed(IEnumerable dependencyNames) => - new DependencyDownloadFailedError(dependencyNames); - - public static UnchainedLaunchFailure LaunchCancelled() => new LaunchCancelledError(); - public static UnchainedLaunchFailure MissingArguments() => new MissingArgumentsError(); - - public LaunchFailed AsLaunchFailed(string args) => new LaunchFailed("unknown", args, this); -} - -public record LaunchOptions( - IEnumerable EnabledReleases, - Option ServerBrowserBackend, - string LaunchArgs, - bool CheckForDependencyUpdates, //TODO: remove this property - bool AllowUnstablePluginReleases, - Option SavedDirSuffix, - Option ServerLaunchOptions -) { - public IReadOnlyList ToCLIArgs() { - var args = new List { - new RawArgs(LaunchArgs), - new UEParameter("-saveddirsuffix", SavedDirSuffix.IfNone("Unchained")), - new Flag("-unchained") - }; - - ServerLaunchOptions.IfSome(opts => args.AddRange(opts.ToCLIArgs())); - ServerBrowserBackend.IfSome(backend => args.Add(new Parameter("--server-browser-backend", backend))); - - return args; - } -}; - -public enum CensorArg { - None, - Standard, - Sex, - Zealous -} - -public record DiscordIntegrationLaunchOptions( - string BotToken, - Option AdminChannelId, - Option GeneralChannelId, - Option DashboardChannelId, - Option EventLogChannelId, - Option AdminRoleId, - bool MentionAdmins -) { - public IReadOnlyList ToCLIArgs() { - var args = new List { - new Parameter("--discord-bot-token", BotToken) - }; - - AdminChannelId.IfSome(id => args.Add(new Parameter("--discord-admin-channel-id", id))); - GeneralChannelId.IfSome(id => args.Add(new Parameter("--discord-general-channel-id", id))); - DashboardChannelId.IfSome(id => args.Add(new Parameter("--discord-dashboard-channel-id", id))); - EventLogChannelId.IfSome(id => args.Add(new Parameter("--discord-event-log-channel-id", id))); - AdminRoleId.IfSome(id => args.Add(new Parameter("--discord-admin-role-id", id))); - - if (!MentionAdmins) { - args.Add(new Parameter("--discord-mention-admins", "false")); - } - - return args; - } -} - -public record ServerLaunchOptions( - bool Headless, - string Name, - string Description, - bool RegisterWithBackend, - Option Password, - string Map, - int GamePort, - int BeaconPort, - int QueryPort, - int RconPort, - int? FFATimeLimit, - int? FFAScoreLimit, - int? TDMTimeLimit, - int? TDMTicketCount, - int? PlayerBotCount, - int? WarmupTime, - Option LocalIp, - IEnumerable ServerMods, - Option DiscordIntegration, - bool DesyncPatch, - bool UseBackendBanlist, - CensorArg CensorMode -) { - public IReadOnlyList ToCLIArgs() { - var args = new List() { - new UEINIParameter("Game", "/Script/TBL.TBLGameMode", "ServerName", Name), - new Parameter("--next-map-name", Map), - new UEParameter("Port", GamePort.ToString()), - new UEParameter("GameServerPingPort", BeaconPort.ToString()), - new UEParameter("GameServerQueryPort", QueryPort.ToString()), - new Parameter("-rcon", RconPort.ToString()), - new Parameter("--server-browser-description", Description), - new Parameter("--motd", Description), - new Parameter("--censor-mode", CensorMode.ToString().ToLower()) - }; - - if (Headless) { - args.Add(new Flag("-nullrhi")); - args.Add(new Flag("-unattended")); - args.Add(new Flag("-nosound")); - } - - if (RegisterWithBackend) - args.Add(new Flag("--register")); - - if (UseBackendBanlist) - args.Add(new Flag("--use-backend-banlist")); - - Password.IfSome(password => args.Add(new UEParameter("ServerPassword", password.Trim()))); - - if (ServerMods.Any()) - args.Add(new Parameter("--server-mods", string.Join(",", ServerMods.Distinct()))); - - LocalIp.IfSome(ip => args.Add(new Parameter("--local-ip", ip))); - - FFAScoreLimit.IfSome(limit => args.Add(new UEMapUrlParameter("FFAScoreLimit", limit.ToString()))); - FFATimeLimit.IfSome(limit => args.Add(new UEMapUrlParameter("FFATimeLimit", limit.ToString()))); - TDMTimeLimit.IfSome(limit => args.Add(new UEMapUrlParameter("TDMTimeLimit", limit.ToString()))); - TDMTicketCount.IfSome(count => args.Add(new UEMapUrlParameter("TDMTicketCount", count.ToString()))); - PlayerBotCount.IfSome(count => args.Add(new UEMapUrlParameter("NumPlayerBots", count.ToString()))); - WarmupTime.IfSome(time => args.Add(new UEMapUrlParameter("WarmupTime", time.ToString()))); - - DiscordIntegration.IfSome(discord => args.AddRange(discord.ToCLIArgs())); - - if (DesyncPatch) - args.Add(new Flag("--desync-patch")); - - return args; - } -}; - - -public abstract record CLIArg(string Rendered); - -public record RawArgs(string Args) : CLIArg(Args); -public record Flag(string FlagName) : CLIArg(FlagName); -public record Parameter(string ParamName, string Value) : CLIArg($"{ParamName} {ArgumentEscaper.Escape(Value)}"); -public record UEParameter(string ParamName, string Value) : CLIArg($"{ParamName}={ArgumentEscaper.Escape(Value)}"); -public record UEINIParameter(string Type, string Section, string Key, string Value) : CLIArg($"-ini:{Type}:[{Section}]:{Key}={ArgumentEscaper.Escape(Value)}"); +using LanguageExt; +using LanguageExt.Common; +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Processes.Chivalry; + +using static LanguageExt.Prelude; +public interface IChivalry2Launcher { + /// + /// Launches a vanilla game. Implementations may do extra work to enable client side pak file loading + /// + /// + /// + /// Left if the game failed to launch. + /// Right if the game was launched successfully. + /// + Task> Launch(LaunchOptions options); +} + +public abstract record UnchainedLaunchFailure(string Message, int Code, Option Underlying) : Expected(Message, Code, Underlying) { + public record InjectionFailedError() : UnchainedLaunchFailure("Failed to inject process with unchained code.", 0, None); + public record LaunchFailedError(LaunchFailed Failure) : UnchainedLaunchFailure(Failure.Message, 0, Failure.Underlying); + public record DependencyDownloadFailedError(IEnumerable DependencyNames) + : UnchainedLaunchFailure($"Failed to download dependencies '{string.Join("', '", DependencyNames)}'", 0, None); + public record LaunchCancelledError() : UnchainedLaunchFailure($"Launch Cancelled", 0, None); + public record MissingArgumentsError() : UnchainedLaunchFailure("Necessary modded launch arguments not provided.", 0, None); + + + public static UnchainedLaunchFailure InjectionFailed() => + new InjectionFailedError(); + public static UnchainedLaunchFailure LaunchFailed(LaunchFailed failure) => new LaunchFailedError(failure); + public static UnchainedLaunchFailure DependencyDownloadFailed(IEnumerable dependencyNames) => + new DependencyDownloadFailedError(dependencyNames); + + public static UnchainedLaunchFailure LaunchCancelled() => new LaunchCancelledError(); + public static UnchainedLaunchFailure MissingArguments() => new MissingArgumentsError(); + + public LaunchFailed AsLaunchFailed(string args) => new LaunchFailed("unknown", args, this); +} + +public record LaunchOptions( + IEnumerable EnabledReleases, + Option ServerBrowserBackend, + string LaunchArgs, + bool CheckForDependencyUpdates, //TODO: remove this property + bool AllowUnstablePluginReleases, + Option SavedDirSuffix, + Option ServerLaunchOptions +) { + public IReadOnlyList EnabledDlls { get; init; } = []; + public IReadOnlyList ToCLIArgs() { + var args = new List { + new RawArgs(LaunchArgs), + new UEParameter("-saveddirsuffix", SavedDirSuffix.IfNone("Unchained")), + new Flag("-unchained") + }; + + ServerLaunchOptions.IfSome(opts => args.AddRange(opts.ToCLIArgs())); + ServerBrowserBackend.IfSome(backend => args.Add(new Parameter("--server-browser-backend", backend))); + + return args; + } +}; + +public enum CensorArg { + None, + Standard, + Sex, + Zealous +} + +public record DiscordIntegrationLaunchOptions( + string BotToken, + Option AdminChannelId, + Option GeneralChannelId, + Option DashboardChannelId, + Option EventLogChannelId, + Option AdminRoleId, + bool MentionAdmins +) { + public IReadOnlyList ToCLIArgs() { + var args = new List { + new Parameter("--discord-bot-token", BotToken) + }; + + AdminChannelId.IfSome(id => args.Add(new Parameter("--discord-admin-channel-id", id))); + GeneralChannelId.IfSome(id => args.Add(new Parameter("--discord-general-channel-id", id))); + DashboardChannelId.IfSome(id => args.Add(new Parameter("--discord-dashboard-channel-id", id))); + EventLogChannelId.IfSome(id => args.Add(new Parameter("--discord-event-log-channel-id", id))); + AdminRoleId.IfSome(id => args.Add(new Parameter("--discord-admin-role-id", id))); + + if (!MentionAdmins) { + args.Add(new Parameter("--discord-mention-admins", "false")); + } + + return args; + } +} + +public record ServerLaunchOptions( + bool Headless, + string Name, + string Description, + bool RegisterWithBackend, + Option Password, + string Map, + int GamePort, + int BeaconPort, + int QueryPort, + int RconPort, + int? FFATimeLimit, + int? FFAScoreLimit, + int? TDMTimeLimit, + int? TDMTicketCount, + int? PlayerBotCount, + int? WarmupTime, + Option LocalIp, + IEnumerable ServerMods, + Option DiscordIntegration, + bool DesyncPatch, + bool UseBackendBanlist, + CensorArg CensorMode +) { + public bool SuppressServerNameOverride { get; init; } + + public IReadOnlyList ToCLIArgs() { + var args = new List() { + new UEINIParameter("Game", "/Script/TBL.TBLGameMode", "ServerName", Name), + new Parameter("--next-map-name", Map), + new UEParameter("Port", GamePort.ToString()), + new UEParameter("GameServerPingPort", BeaconPort.ToString()), + new UEParameter("GameServerQueryPort", QueryPort.ToString()), + new Parameter("-rcon", RconPort.ToString()), + new Parameter("--server-browser-description", Description), + new Parameter("--motd", Description), + new Parameter("--censor-mode", CensorMode.ToString().ToLower()) + }; + + if (SuppressServerNameOverride) args.RemoveAll(arg => arg is UEINIParameter { Key: "ServerName" }); + + if (Headless) { + args.Add(new Flag("-nullrhi")); + args.Add(new Flag("-unattended")); + args.Add(new Flag("-nosound")); + } + + if (RegisterWithBackend) + args.Add(new Flag("--register")); + + if (UseBackendBanlist) + args.Add(new Flag("--use-backend-banlist")); + + Password.IfSome(password => args.Add(new UEParameter("ServerPassword", password.Trim()))); + + if (ServerMods.Any()) + args.Add(new Parameter("--server-mods", string.Join(",", ServerMods.Distinct()))); + + LocalIp.IfSome(ip => args.Add(new Parameter("--local-ip", ip))); + + FFAScoreLimit.IfSome(limit => args.Add(new UEMapUrlParameter("FFAScoreLimit", limit.ToString()))); + FFATimeLimit.IfSome(limit => args.Add(new UEMapUrlParameter("FFATimeLimit", limit.ToString()))); + TDMTimeLimit.IfSome(limit => args.Add(new UEMapUrlParameter("TDMTimeLimit", limit.ToString()))); + TDMTicketCount.IfSome(count => args.Add(new UEMapUrlParameter("TDMTicketCount", count.ToString()))); + PlayerBotCount.IfSome(count => args.Add(new UEMapUrlParameter("NumPlayerBots", count.ToString()))); + WarmupTime.IfSome(time => args.Add(new UEMapUrlParameter("WarmupTime", time.ToString()))); + + DiscordIntegration.IfSome(discord => args.AddRange(discord.ToCLIArgs())); + + if (DesyncPatch) + args.Add(new Flag("--desync-patch")); + + return args; + } +}; + + +public abstract record CLIArg(string Rendered); + +public record RawArgs(string Args) : CLIArg(Args); +public record Flag(string FlagName) : CLIArg(FlagName); +public record Parameter(string ParamName, string Value) : CLIArg($"{ParamName} {ArgumentEscaper.Escape(Value)}"); +public record UEParameter(string ParamName, string Value) : CLIArg($"{ParamName}={ArgumentEscaper.Escape(Value)}"); +public record UEINIParameter(string Type, string Section, string Key, string Value) : CLIArg($"-ini:{Type}:[{Section}]:{Key}={ArgumentEscaper.Escape(Value)}"); public record UEMapUrlParameter(string Key, string Value) : CLIArg($"?{Key}={Value}"); \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs index 50fa3f4a..ce505140 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs @@ -72,6 +72,38 @@ private async Task ValidateHashes(IEnumerable releases) { } public async Task> PrepareLaunch(LaunchOptions options) { + ManagedPakInventory? inventory; + try { + inventory = ManagedPakInventory.Reconcile(_modManager); + } + catch (Exception e) when (e is InvalidDataException or IOException or UnauthorizedAccessException) { + ReportInventoryFailure(e); + return None; + } + var saved = true; + Option result; + try { + result = await PrepareMods(options); + } + finally { + // Persist physical ownership even when preparation fails partway through. + try { inventory?.Save(); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + saved = false; + ReportInventoryFailure(e); + } + } + return saved ? result : None; + } + + private void ReportInventoryFailure(Exception e) { + _logger.Error($"Failed to reconcile managed pak inventory: {e.Message}"); + _userDialogueSpawner.DisplayMessage( + $"Could not reconcile managed pak inventory: {e.Message}\n" + + "Launch cancelled. Resolve the pak setup errors before launching."); + } + + private async Task> PrepareMods(LaunchOptions options) { // Get all releases that will be installed (enabled mods + dependencies) var releasesToInstall = _modManager.GetEnabledAndDependencyReleases().ToList(); _logger.LogListInfo("Installing the following releases:", releasesToInstall.Select(ReleaseCoordinates.FromRelease).ToList()); diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/NoSigPreparer.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/NoSigPreparer.cs index c06dba4e..bbee502b 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/NoSigPreparer.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/NoSigPreparer.cs @@ -1,6 +1,7 @@ using LanguageExt; using log4net; using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Utilities; using static LanguageExt.Prelude; namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { @@ -19,6 +20,9 @@ public class NoSigPreparer : IChivalry2LaunchPreparer { public static IChivalry2LaunchPreparer Create(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) => new NoSigPreparer(pakDir, userDialogueSpawner); + public static IChivalry2LaunchPreparer CreateForInstallation(string installation, IUserDialogueSpawner userDialogueSpawner) => + Create(new PakDir(Path.Combine(installation, FilePaths.PakDir), []), userDialogueSpawner); + private NoSigPreparer(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) { PakDir = pakDir; _userDialogueSpawner = userDialogueSpawner; diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/SigPreparer.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/SigPreparer.cs index 6d490bcb..3989b064 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/SigPreparer.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/SigPreparer.cs @@ -15,20 +15,26 @@ public class SigPreparer : IChivalry2LaunchPreparer { private readonly ILog _logger = LogManager.GetLogger(nameof(SigPreparer)); private readonly IUserDialogueSpawner _userDialogueSpawner; + private readonly bool _deleteOrphanedSigs; public static IChivalry2LaunchPreparer Create(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) => new SigPreparer(pakDir, userDialogueSpawner); - private SigPreparer(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) { + // Profile ownership cleanup removes managed signatures; unknown signatures must survive. + public static IChivalry2LaunchPreparer CreateForProfile(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) => + new SigPreparer(pakDir, userDialogueSpawner, false); + + private SigPreparer(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner, bool deleteOrphanedSigs = true) { PakDir = pakDir; _userDialogueSpawner = userDialogueSpawner; + _deleteOrphanedSigs = deleteOrphanedSigs; } public Task> PrepareLaunch(Unit input) { _logger.Info("Ensuring sigs are set up for all pak files."); var result = PakDir .SignAll() - .Bind(_ => PakDir.DeleteOrphanedSigs()) + .Bind(result => _deleteOrphanedSigs ? PakDir.DeleteOrphanedSigs() : result) .Match( _ => Some(input), errors => { diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs index c1624b3f..9741ea98 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs @@ -79,26 +79,31 @@ public async Task> TryLaunch(LaunchOptio .Concat(cliArgs.Select(x => x.Rendered)) .Where(x => !string.IsNullOrWhiteSpace(x))); - var launchResult = Launcher.Launch(workingDir, launchOptString); + var injectionFailed = false; + var launchResult = Launcher.Launch(workingDir, launchOptString, process => { + Logger.Info($"Injecting DLLs into {process.Id} before resuming the game."); + try { + injectionFailed = !ProcessInjector.Inject(process, server: updatedLaunchOpts.ServerLaunchOptions.IsSome); + return !injectionFailed; + } + catch { + injectionFailed = true; + throw; + } + }); - return launchResult.Match( + return launchResult.Match>( Left: error => { Logger.Error(error); + if (injectionFailed) return Left(UnchainedLaunchFailure.InjectionFailed()); return Left(UnchainedLaunchFailure.LaunchFailed(error)); }, Right: proc => { Logger.Info("Successfully launched Chivalry 2 process."); - return InjectDLLs(proc, server: updatedLaunchOpts.ServerLaunchOptions.IsSome); + return Right(proc); } ); } - private Either InjectDLLs(Process process, bool server) { - Logger.Info($"Injecting DLLs into {process.Id}"); - if (ProcessInjector.Inject(process, server)) return Right(process); - - process.Kill(); - return Left(UnchainedLaunchFailure.InjectionFailed()); - } } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/DllInjector.cs b/UnchainedLauncher.Core/Services/Processes/DllInjector.cs index 98d058be..b9bc74fa 100644 --- a/UnchainedLauncher.Core/Services/Processes/DllInjector.cs +++ b/UnchainedLauncher.Core/Services/Processes/DllInjector.cs @@ -1,144 +1,170 @@ -using log4net; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using UnchainedLauncher.Core.Extensions; -using UnchainedLauncher.Core.Services.Dlls; - -namespace UnchainedLauncher.Core.Services.Processes { - //Code modified/adapted from https://codingvision.net/c-inject-a-dll-into-a-process-w-createremotethread - public class DllInjector : IProcessInjector { - private readonly ILog _logger = LogManager.GetLogger(nameof(DllInjector)); - - [DllImport("kernel32.dll")] - public static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto)] - public static extern IntPtr GetModuleHandle(string lpModuleName); - - [DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)] - static extern IntPtr GetProcAddress(IntPtr hModule, string procName); - - [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] - static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, - uint dwSize, uint flAllocationType, uint flProtect); - - [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] - static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, - uint dwSize, uint dwFreeType); - - [DllImport("kernel32.dll", SetLastError = true)] - static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out UIntPtr lpNumberOfBytesWritten); - - [DllImport("kernel32.dll")] - static extern IntPtr CreateRemoteThread(IntPtr hProcess, - IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); - - [DllImport("kernel32.dll")] - static extern uint WaitForSingleObject(IntPtr hProcess, uint dwMilliseconds); - - [DllImport("kernel32.dll")] - static extern bool CloseHandle(IntPtr handle); - - [DllImport("kernel32.dll")] - static extern bool GetExitCodeThread(IntPtr thread, out uint exitCode); - - // privileges - const int ProcessCreateThread = 0x0002; - const int ProcessQueryInformation = 0x0400; - const int ProcessVmOperation = 0x0008; - const int ProcessVmWrite = 0x0020; - const int ProcessVmRead = 0x0010; - - // used for memory allocation - const uint MemCommit = 0x00001000; - const uint MemReserve = 0x00002000; - const uint MemRelease = 0x00008000; - - const uint PageReadwrite = 4; - - private readonly DllManager _dllManager; - - public DllInjector(DllManager dllManager) { - _dllManager = dllManager; - } - - public bool Inject(Process p) => Inject(p, server: false); - - public bool Inject(Process p, bool server) { - IReadOnlyList files = Array.Empty(); - var procHandle = IntPtr.Zero; - var allocMemAddress = IntPtr.Zero; - try { - files = _dllManager.OpenForInjection(server); - var paths = files.Select(file => file.Path).ToArray(); - - if (paths.Length == 0) { - _logger.Info("No dlls present for injection"); - return true; - } - - //Paths to be injected MUST be absolute - paths = paths.Select(p => Path.GetFullPath(p)).ToArray(); - - _logger.LogListInfo("Injecting DLLs: ", paths); - - procHandle = OpenProcess(ProcessCreateThread | - ProcessQueryInformation | ProcessVmOperation | - ProcessVmWrite | ProcessVmRead, false, p.Id); - if (procHandle == IntPtr.Zero) { - return false; - } - - var maxLen = paths.Max(p => p.Length); - var allocSize = checked((uint)((maxLen + 1) * sizeof(char))); - - // Unicode paths must be encoded exactly as LoadLibraryW expects. - var loadLibraryAddr = GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryW"); - if (loadLibraryAddr == IntPtr.Zero) { - return false; - } - - // alocating some memory on the target process - enough to store the name of the dll - // and storing its address in a pointer - allocMemAddress = VirtualAllocEx(procHandle, - IntPtr.Zero, - allocSize, - MemCommit | MemReserve, PageReadwrite); - if (allocMemAddress == IntPtr.Zero) { - return false; - } - - foreach (var path in paths) { - // writing the name of the dll there - var bytes = Encoding.Unicode.GetBytes(path + '\0'); - var res = WriteProcessMemory(procHandle, allocMemAddress, - bytes, (uint)bytes.Length, - out var bytesWritten); - if (!res || bytesWritten.ToUInt64() != (ulong)bytes.Length) { return false; } - - //inject - var thread = CreateRemoteThread(procHandle, IntPtr.Zero, 0, loadLibraryAddr, allocMemAddress, 0, - IntPtr.Zero); - if (thread == IntPtr.Zero) return false; - try { - if (WaitForSingleObject(thread, unchecked((uint)-1)) != 0 || - !GetExitCodeThread(thread, out var result) || result == 0) return false; - } - finally { CloseHandle(thread); } - } - - return true; - } - catch (Exception e) { - _logger.Error("Injection failed", e); - return false; - } - finally { - if (allocMemAddress != IntPtr.Zero) VirtualFreeEx(procHandle, allocMemAddress, 0, MemRelease); - if (procHandle != IntPtr.Zero) CloseHandle(procHandle); - foreach (var file in files) file.Dispose(); - } - } - } +using log4net; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using UnchainedLauncher.Core.Extensions; +using UnchainedLauncher.Core.Services.Dlls; + +namespace UnchainedLauncher.Core.Services.Processes { + //Code modified/adapted from https://codingvision.net/c-inject-a-dll-into-a-process-w-createremotethread + public class DllInjector : IProcessInjector { + private readonly ILog _logger = LogManager.GetLogger(nameof(DllInjector)); + + [DllImport("kernel32.dll")] + public static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto)] + public static extern IntPtr GetModuleHandle(string lpModuleName); + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)] + static extern IntPtr GetProcAddress(IntPtr hModule, string procName); + + [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] + static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, + uint dwSize, uint flAllocationType, uint flProtect); + + [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] + static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, + uint dwSize, uint dwFreeType); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out UIntPtr lpNumberOfBytesWritten); + + [DllImport("kernel32.dll")] + static extern IntPtr CreateRemoteThread(IntPtr hProcess, + IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); + + [DllImport("kernel32.dll")] + static extern uint WaitForSingleObject(IntPtr hProcess, uint dwMilliseconds); + + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll")] + static extern bool GetExitCodeThread(IntPtr thread, out uint exitCode); + + // privileges + const int ProcessCreateThread = 0x0002; + const int ProcessQueryInformation = 0x0400; + const int ProcessVmOperation = 0x0008; + const int ProcessVmWrite = 0x0020; + const int ProcessVmRead = 0x0010; + + // used for memory allocation + const uint MemCommit = 0x00001000; + const uint MemReserve = 0x00002000; + const uint MemRelease = 0x00008000; + + const uint PageReadwrite = 4; + + private readonly DllManager? _dllManager; + private readonly string? _dllDirectory; + private readonly bool _unsafeLoadAllDlls; + + public DllInjector(DllManager dllManager, string? dllDirectory = null, bool unsafeLoadAllDlls = false) { + _dllManager = dllManager; + _dllDirectory = dllDirectory; + _unsafeLoadAllDlls = unsafeLoadAllDlls; + if (unsafeLoadAllDlls && dllDirectory == null) throw new ArgumentException("Unsafe injection requires a plugin directory.", nameof(dllDirectory)); + } + + public DllInjector(string dllDirectory, bool unsafeLoadAllDlls = false) { + _dllDirectory = dllDirectory; + _unsafeLoadAllDlls = unsafeLoadAllDlls; + } + + public IEnumerable GetDllPaths(bool server = false) { + if (_unsafeLoadAllDlls) return UnsafePaths(); + if (_dllManager == null) return []; + var files = _dllManager.OpenForInjection(server); + try { return files.Select(file => file.Path).ToArray(); } + finally { foreach (var file in files) file.Dispose(); } + } + + private string[] UnsafePaths() => _dllDirectory != null && Directory.Exists(_dllDirectory) + ? Directory.EnumerateFiles(_dllDirectory, "*", SearchOption.AllDirectories) + .Where(path => Path.GetExtension(path).Equals(".dll", StringComparison.OrdinalIgnoreCase)) + .Select(Path.GetFullPath).OrderBy(path => path, StringComparer.Ordinal).ToArray() + : []; + + public bool Inject(Process p) => Inject(p, server: false); + + public bool Inject(Process p, bool server) { + IReadOnlyList files = Array.Empty(); + var procHandle = IntPtr.Zero; + var allocMemAddress = IntPtr.Zero; + try { + if (_dllManager == null && !_unsafeLoadAllDlls) return false; + if (_dllManager != null) files = _dllManager.OpenForInjection(server); + var paths = files.Select(file => file.Path).ToArray(); + if (_unsafeLoadAllDlls) paths = paths.Concat(UnsafePaths()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + if (paths.Length == 0) { + _logger.Info("No dlls present for injection"); + return true; + } + + //Paths to be injected MUST be absolute + paths = paths.Select(p => Path.GetFullPath(p)).ToArray(); + + _logger.LogListInfo("Injecting DLLs: ", paths); + + procHandle = OpenProcess(ProcessCreateThread | + ProcessQueryInformation | ProcessVmOperation | + ProcessVmWrite | ProcessVmRead, false, p.Id); + if (procHandle == IntPtr.Zero) { + return false; + } + + var maxLen = paths.Max(p => p.Length); + var allocSize = checked((uint)((maxLen + 1) * sizeof(char))); + + // Unicode paths must be encoded exactly as LoadLibraryW expects. + var loadLibraryAddr = GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryW"); + if (loadLibraryAddr == IntPtr.Zero) { + return false; + } + + // alocating some memory on the target process - enough to store the name of the dll + // and storing its address in a pointer + allocMemAddress = VirtualAllocEx(procHandle, + IntPtr.Zero, + allocSize, + MemCommit | MemReserve, PageReadwrite); + if (allocMemAddress == IntPtr.Zero) { + return false; + } + + foreach (var path in paths) { + // writing the name of the dll there + var bytes = Encoding.Unicode.GetBytes(path + '\0'); + var res = WriteProcessMemory(procHandle, allocMemAddress, + bytes, (uint)bytes.Length, + out var bytesWritten); + if (!res || bytesWritten.ToUInt64() != (ulong)bytes.Length) { return false; } + + //inject + var thread = CreateRemoteThread(procHandle, IntPtr.Zero, 0, loadLibraryAddr, allocMemAddress, 0, + IntPtr.Zero); + if (thread == IntPtr.Zero) return false; + try { + if (WaitForSingleObject(thread, unchecked((uint)-1)) != 0 || + !GetExitCodeThread(thread, out var result) || result == 0) return false; + } + finally { CloseHandle(thread); } + } + + return true; + } + catch (Exception e) { + _logger.Error("Injection failed", e); + return false; + } + finally { + if (allocMemAddress != IntPtr.Zero) VirtualFreeEx(procHandle, allocMemAddress, 0, MemRelease); + if (procHandle != IntPtr.Zero) CloseHandle(procHandle); + foreach (var file in files) file.Dispose(); + } + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/ProcessLauncher.cs b/UnchainedLauncher.Core/Services/Processes/ProcessLauncher.cs index 30e4ab74..f6390761 100644 --- a/UnchainedLauncher.Core/Services/Processes/ProcessLauncher.cs +++ b/UnchainedLauncher.Core/Services/Processes/ProcessLauncher.cs @@ -10,6 +10,10 @@ namespace UnchainedLauncher.Core.Services.Processes { public interface IProcessLauncher { string Executable { get; } public Either Launch(string workingDirectory, string args); + + // Diagnostic launchers and test doubles do not own a suspended native thread. + public Either Launch(string workingDirectory, string args, Func initialize) => + Launch(workingDirectory, args).Bind(process => ProcessLauncher.Initialize(process, initialize, Executable, args)); } public class PowershellProcessLauncher : IProcessLauncher { @@ -57,6 +61,27 @@ public ProcessLauncher(string executable) { Executable = executable; } + public Either Launch(string workingDirectory, string args, Func initialize) { + if (OperatingSystem.IsWindows()) + return SuspendedProcessLauncher.Launch(Executable, workingDirectory, args, initialize); + // Game launching is Windows-only; retain portable argument-probe support. + return Launch(workingDirectory, args).Bind(process => Initialize(process, initialize, Executable, args)); + } + + internal static Either Initialize(Process process, Func initialize, + string executable, string args) { + try { + if (initialize(process)) return Right(process); + throw new InvalidOperationException("DLL injection failed."); + } + catch (Exception error) { + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } + catch (Exception cleanupError) { Logger.Error("Could not terminate failed launch.", cleanupError); } + finally { process.Dispose(); } + return Left(new LaunchFailed(executable, args, error)); + } + } + /// /// Creates a new process with the provided arguments and injects the DLLs. /// diff --git a/UnchainedLauncher.Core/Services/Processes/SuspendedProcessLauncher.cs b/UnchainedLauncher.Core/Services/Processes/SuspendedProcessLauncher.cs new file mode 100644 index 00000000..61c50d7c --- /dev/null +++ b/UnchainedLauncher.Core/Services/Processes/SuspendedProcessLauncher.cs @@ -0,0 +1,87 @@ +using LanguageExt; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Processes; + +internal static class SuspendedProcessLauncher { + private static readonly log4net.ILog Logger = log4net.LogManager.GetLogger(typeof(SuspendedProcessLauncher)); + private const uint CreateSuspended = 0x00000004; + internal static Either Launch(string executable, string workingDirectory, string args, + Func initialize) { + var startup = new StartupInfo { Size = Marshal.SizeOf() }; + ProcessInformation native = default; + Process? process = null; + var resumed = false; + try { + var path = Path.GetFullPath(executable); + var commandLine = new StringBuilder($"\"{path}\" {args}"); + if (!CreateProcessW(path, commandLine, IntPtr.Zero, IntPtr.Zero, false, CreateSuspended, + IntPtr.Zero, Path.GetFullPath(workingDirectory), ref startup, out native)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not create suspended process."); + + process = Process.GetProcessById(native.ProcessId); + // Open the managed handle while the original process handle still pins its identity. + _ = process.SafeHandle; + if (!initialize(process)) throw new InvalidOperationException("DLL injection failed."); + var previousSuspendCount = ResumeThread(native.Thread); + if (previousSuspendCount == uint.MaxValue) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not resume the game main thread."); + if (previousSuspendCount != 1) + throw new InvalidOperationException("Unexpected game main-thread suspend count."); + resumed = true; + return Right(process); + } + catch (Exception error) { + return Left(new LaunchFailed(executable, args, error)); + } + finally { + if (!resumed && native.Process != IntPtr.Zero) { + // Never resume a game after failed injection, including exceptions from the injector. + if (!TerminateProcess(native.Process, 1)) + Logger.Error("Could not terminate failed suspended launch.", new Win32Exception(Marshal.GetLastWin32Error())); + } + if (native.Thread != IntPtr.Zero) CloseHandle(native.Thread); + if (native.Process != IntPtr.Zero) CloseHandle(native.Process); + if (!resumed) process?.Dispose(); + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo { + public int Size; + public string? Reserved; + public string? Desktop; + public string? Title; + public uint X, Y, XSize, YSize, XCountChars, YCountChars, FillAttribute, Flags; + public ushort ShowWindow, ReservedSize; + public IntPtr ReservedBytes, StandardInput, StandardOutput, StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation { + public IntPtr Process, Thread; + public int ProcessId, ThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, + IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, + out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateProcess(IntPtr process, uint exitCode); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/UnsafeDllLoading.cs b/UnchainedLauncher.Core/Services/Processes/UnsafeDllLoading.cs new file mode 100644 index 00000000..b53c9a23 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Processes/UnsafeDllLoading.cs @@ -0,0 +1,39 @@ +using System.CommandLine; + +namespace UnchainedLauncher.Core.Services.Processes; + +/// Process-local command-line permission, never a persisted setting. +public static class UnsafeDllLoading { + public const string FlagName = "--unsafe-very-dangerous-load-all-dlls"; + + public static Option CreateOption(bool recursive = false) { + var option = new Option(FlagName) { Hidden = true, Arity = ArgumentArity.Zero, Recursive = recursive }; + return option; + } + + public static bool Read(ParseResult parsed, Option option, IReadOnlyList commandLine) { + if (parsed.GetResult(option) is not { } result) return false; + if (result.IdentifierTokenCount == 0) return false; + // System.CommandLine tokenizes --flag=true as --flag plus a positional + // token. Permission requires the original literal flag, not that form. + if (!commandLine.Contains(FlagName, StringComparer.Ordinal) || result.IdentifierTokenCount != 1 || result.Tokens.Count != 0) + throw new ArgumentException($"{FlagName} must be supplied once as a value-less command-line flag."); + var errors = parsed.Errors.Where(error => error.SymbolResult == result).Select(error => error.Message).ToArray(); + if (errors.Length != 0) throw new ArgumentException(string.Join(Environment.NewLine, errors)); + return parsed.GetValue(option); + } + + public static (bool Enabled, string[] RemainingArguments) ParseGuiArguments(string[] args) { + var separator = Array.IndexOf(args, "--"); + var prefix = separator < 0 ? args : args[..separator]; + var root = new RootCommand { TreatUnmatchedTokensAsErrors = false }; + var option = CreateOption(); + root.Options.Add(option); + var parsed = root.Parse(prefix, new ParserConfiguration { ResponseFileTokenReplacer = null }); + var enabled = Read(parsed, option, prefix); + // Only a successfully parsed, exact permission token is consumed; game/auth + // arguments (including those after --) remain byte-for-byte unchanged. + var remaining = enabled ? prefix.Where(argument => argument != FlagName) : prefix; + return (enabled, remaining.Concat(separator < 0 ? [] : args[separator..]).ToArray()); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj b/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj index 3c20dea0..aa9895e6 100644 --- a/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj +++ b/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj @@ -27,6 +27,8 @@ + + diff --git a/UnchainedLauncher.Core/Utilities/ArgumentEscaper.cs b/UnchainedLauncher.Core/Utilities/ArgumentEscaper.cs index 172dc5c2..eda68c98 100644 --- a/UnchainedLauncher.Core/Utilities/ArgumentEscaper.cs +++ b/UnchainedLauncher.Core/Utilities/ArgumentEscaper.cs @@ -1,16 +1,7 @@ -namespace UnchainedLauncher.Core.Utilities { - public static class ArgumentEscaper { - public static string Escape(string argument) { - var escaped = argument - .Replace("\"", "\\\"") - .ReplaceLineEndings("\\r\\n"); - - // Quote if contains spaces or is empty - if (string.IsNullOrEmpty(argument) || argument.Contains(' ')) { - return $"\"{escaped}\""; - } - - return escaped; - } - } +namespace UnchainedLauncher.Core.Utilities { + public static class ArgumentEscaper { + // Keep the raw command-line contracts used by Core and GUI, but delegate argv quoting + // to CliWrap's .NET-derived implementation (including backslashes, quotes and whitespace). + public static string Escape(string argument) => CliWrap.Builders.ArgumentsBuilder.Escape(argument); + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Utilities/AssetCacheFiles.cs b/UnchainedLauncher.Core/Utilities/AssetCacheFiles.cs new file mode 100644 index 00000000..1fb49f17 --- /dev/null +++ b/UnchainedLauncher.Core/Utilities/AssetCacheFiles.cs @@ -0,0 +1,26 @@ +using System.Runtime.InteropServices; + +namespace UnchainedLauncher.Core.Utilities; + +internal static class AssetCacheFiles { + internal static async Task LockAsync(string path, CancellationToken cancellationToken) { + var deadline = DateTime.UtcNow.AddMinutes(30); + while (true) { + cancellationToken.ThrowIfCancellationRequested(); + try { return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } + catch (IOException) when (DateTime.UtcNow < deadline) { await Task.Delay(100, cancellationToken); } + } + } + + // Direct publication is atomic and never replaces an occupied destination. + internal static bool TryCreateHardLink(string destination, string source) => OperatingSystem.IsWindows() + ? CreateHardLinkW(destination, source, IntPtr.Zero) + : OperatingSystem.IsLinux() && Link(source, destination) == 0; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateHardLinkW(string fileName, string existingFileName, IntPtr securityAttributes); + + [DllImport("libc", EntryPoint = "link", SetLastError = true)] + private static extern int Link(string existingFileName, string fileName); +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Utilities/FileHelpers.cs b/UnchainedLauncher.Core/Utilities/FileHelpers.cs index 703adc0f..47ea0869 100644 --- a/UnchainedLauncher.Core/Utilities/FileHelpers.cs +++ b/UnchainedLauncher.Core/Utilities/FileHelpers.cs @@ -1,160 +1,167 @@ -using LanguageExt; -using LanguageExt.Common; -using log4net; - -namespace UnchainedLauncher.Core.Utilities { - using static LanguageExt.Prelude; - public static class FileHelpers { - private static readonly ILog Logger = LogManager.GetLogger(nameof(FileHelpers)); - - public const string UserLockSuffix = ".USER_LOCK"; - - public static bool IsFileLocked(string filePath) { - return File.Exists(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)) || - File.Exists(filePath + UserLockSuffix); - } - - public static bool DeleteFiles(IEnumerable filePaths) { - Logger.Info("Deleting files: " + string.Join("\n ", filePaths)); - // Delete all files in a transaction. - // If any of them are locked, don't delete any of them. - if (filePaths.Any(IsFileLocked)) { - var lockedFiles = filePaths.Where(IsFileLocked); - Logger.Warn($"Not deleting files because some of them are locked:\n" + string.Join("\n ", lockedFiles)); - return false; - } - - foreach (var filePath in filePaths) { - File.Delete(filePath); - } - - return true; - } - - public static bool DeleteFile(string filePath) { - if (IsFileLocked(filePath)) { - Logger.Warn($"Not deleting file because it is locked: {filePath}"); - return false; - } - - if (File.Exists(filePath)) { - Logger.Info("Deleting file: " + filePath); - File.Delete(filePath); - } - - return true; - } - - public static bool DeleteDirectory(string filePath) { - Logger.Info("Deleting directory: " + filePath); - if (!Directory.Exists(filePath)) { - Logger.Warn($"Not deleting directory because it doesn't exist: {filePath}"); - return false; - } - - var files = Directory.GetFiles(filePath, "*", SearchOption.AllDirectories); - var result = DeleteFiles(files); - - if (result) - Directory.Delete(filePath, true); - - return result; - } - - // Currently unused, but maybe someday. - public static void LockFile(string filePath) { - File.Create(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)).Close(); - } - - // Currently unused, but maybe someday. - public static void UnlockFile(string filePath) { - File.Delete(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)); - } - - public static Either Sha512(string filePath) { - return Prelude.Try(() => { - using var sha512 = System.Security.Cryptography.SHA512.Create(); - var bytes = File.ReadAllBytes(filePath); - return BitConverter.ToString(sha512.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant(); - }).ToEither() - .MapLeft(error => new HashFailure(filePath, error)); - } - - - /// - /// Asynchronously computes the SHA-512 hash of a file. - /// - /// - /// An Either representing a failure on the left, or a hash on the right. If Right(None), then the file does not exist. - public static EitherAsync> Sha512Async(string filePath) { - - if (!File.Exists(filePath)) - return Prelude.RightAsync>(Prelude.None); - - return - Prelude - .TryAsync(() => File.ReadAllBytesAsync(filePath)) - .ToEither() - .Map(bytes => { - using var sha512 = System.Security.Cryptography.SHA512.Create(); - return Prelude.Some(BitConverter.ToString(sha512.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant()); - }) - .MapLeft(error => new HashFailure(filePath, error)); - } - } - - public record HashFailure(string FilePath, Error Error) : Expected($"Failed to hash file at {FilePath}. Reason: {Error.Message}", Error.Code, Some(Error)); - - - public class FileWriter { - private static readonly ILog logger = LogManager.GetLogger(nameof(FileWriter)); - - public string FilePath { get; } - public Stream InputStream { get; } - public long Size { get; } - - public FileWriter(string filePath, Stream inputStream, long size) { - FilePath = filePath; - InputStream = inputStream; - Size = size; - } - - public EitherAsync WriteAsync(Option> progress, CancellationToken cancellationToken) { - return Prelude.TryAsync( - async () => { - // Ensure the input stream is readable - if (!InputStream.CanRead) { - logger.Error($"The input stream for file '{FilePath}' is not readable."); - throw new ArgumentException("The input stream is not readable.", nameof(InputStream)); - } - - // Create or overwrite the target file - using (var outputStream = new FileStream(FilePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true)) { - logger.Debug($"Writing file '{FilePath}' from input stream."); - var totalBytesWritten = 0L; // Total bytes written to the file - var buffer = new byte[8 * 1024 * 1024]; // Buffer to hold data from input stream - int bytesRead; - - // While there's data to read from the input stream, read and write asynchronously - var memory = new Memory(buffer); - while ((bytesRead = await InputStream.ReadAsync(memory, cancellationToken)) > 0) { - // Write only the portion of the buffer that was read - await outputStream.WriteAsync(memory[..bytesRead], cancellationToken); - - totalBytesWritten += bytesRead; - var percentage = (double)totalBytesWritten / Size * 100; - - progress.IfSome(p => p.Report(percentage)); - //logger.Debug($"Wrote {totalBytesWritten} bytes to file '{FilePath}'. Progress: {percentage}%"); - } - } - - return default(Unit); - }) - .ToEither(); - } - - - - } +using LanguageExt; +using LanguageExt.Common; +using log4net; + +namespace UnchainedLauncher.Core.Utilities { + using static LanguageExt.Prelude; + public static class FileHelpers { + private static readonly ILog Logger = LogManager.GetLogger(nameof(FileHelpers)); + + public const string UserLockSuffix = ".USER_LOCK"; + + public static bool IsFileLocked(string filePath) { + return File.Exists(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)) || + File.Exists(filePath + UserLockSuffix); + } + + public static bool DeleteFiles(IEnumerable filePaths) { + Logger.Info("Deleting files: " + string.Join("\n ", filePaths)); + // Delete all files in a transaction. + // If any of them are locked, don't delete any of them. + if (filePaths.Any(IsFileLocked)) { + var lockedFiles = filePaths.Where(IsFileLocked); + Logger.Warn($"Not deleting files because some of them are locked:\n" + string.Join("\n ", lockedFiles)); + return false; + } + + foreach (var filePath in filePaths) { + File.Delete(filePath); + } + + return true; + } + + public static bool DeleteFile(string filePath) { + if (IsFileLocked(filePath)) { + Logger.Warn($"Not deleting file because it is locked: {filePath}"); + return false; + } + + if (File.Exists(filePath)) { + Logger.Info("Deleting file: " + filePath); + File.Delete(filePath); + } + + return true; + } + + public static bool DeleteDirectory(string filePath) { + Logger.Info("Deleting directory: " + filePath); + if (!Directory.Exists(filePath)) { + Logger.Warn($"Not deleting directory because it doesn't exist: {filePath}"); + return false; + } + + var files = Directory.GetFiles(filePath, "*", SearchOption.AllDirectories); + var result = DeleteFiles(files); + + if (result) + Directory.Delete(filePath, true); + + return result; + } + + // Currently unused, but maybe someday. + public static void LockFile(string filePath) { + File.Create(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)).Close(); + } + + // Currently unused, but maybe someday. + public static void UnlockFile(string filePath) { + File.Delete(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)); + } + + public static Either Sha512(string filePath) { + return Prelude.Try(() => { + using var sha512 = System.Security.Cryptography.SHA512.Create(); + var bytes = File.ReadAllBytes(filePath); + return BitConverter.ToString(sha512.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant(); + }).ToEither() + .MapLeft(error => new HashFailure(filePath, error)); + } + + + /// + /// Asynchronously computes the SHA-512 hash of a file. + /// + /// + /// An Either representing a failure on the left, or a hash on the right. If Right(None), then the file does not exist. + public static EitherAsync> Sha512Async(string filePath) { + + if (!File.Exists(filePath)) + return Prelude.RightAsync>(Prelude.None); + + return + Prelude + .TryAsync(() => File.ReadAllBytesAsync(filePath)) + .ToEither() + .Map(bytes => { + using var sha512 = System.Security.Cryptography.SHA512.Create(); + return Prelude.Some(BitConverter.ToString(sha512.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant()); + }) + .MapLeft(error => new HashFailure(filePath, error)); + } + } + + public record HashFailure(string FilePath, Error Error) : Expected($"Failed to hash file at {FilePath}. Reason: {Error.Message}", Error.Code, Some(Error)); + + + /// + /// A single-use writer that owns its input stream. WriteAsync disposes the input + /// after success, failure, or cancellation. Dispose the writer if it is not written. + /// + public class FileWriter : IDisposable { + private static readonly ILog logger = LogManager.GetLogger(nameof(FileWriter)); + + public string FilePath { get; } + public Stream InputStream { get; } + public long Size { get; } + + public FileWriter(string filePath, Stream inputStream, long size) { + FilePath = filePath; + InputStream = inputStream; + Size = size; + } + + public EitherAsync WriteAsync(Option> progress, CancellationToken cancellationToken) { + return Prelude.TryAsync( + async () => { + using var inputStream = InputStream; + // Ensure the input stream is readable + if (!InputStream.CanRead) { + logger.Error($"The input stream for file '{FilePath}' is not readable."); + throw new ArgumentException("The input stream is not readable.", nameof(InputStream)); + } + + // Create or overwrite the target file + using (var outputStream = new FileStream(FilePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true)) { + logger.Debug($"Writing file '{FilePath}' from input stream."); + var totalBytesWritten = 0L; // Total bytes written to the file + var buffer = new byte[8 * 1024 * 1024]; // Buffer to hold data from input stream + int bytesRead; + + // While there's data to read from the input stream, read and write asynchronously + var memory = new Memory(buffer); + while ((bytesRead = await InputStream.ReadAsync(memory, cancellationToken)) > 0) { + // Write only the portion of the buffer that was read + await outputStream.WriteAsync(memory[..bytesRead], cancellationToken); + + totalBytesWritten += bytesRead; + var percentage = (double)totalBytesWritten / Size * 100; + + progress.IfSome(p => p.Report(percentage)); + //logger.Debug($"Wrote {totalBytesWritten} bytes to file '{FilePath}'. Progress: {percentage}%"); + } + } + + return default(Unit); + }) + .ToEither(); + } + + public void Dispose() => InputStream.Dispose(); + + + + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Utilities/FilePaths.cs b/UnchainedLauncher.Core/Utilities/FilePaths.cs index d608d124..998617f5 100644 --- a/UnchainedLauncher.Core/Utilities/FilePaths.cs +++ b/UnchainedLauncher.Core/Utilities/FilePaths.cs @@ -16,6 +16,7 @@ public static class FilePaths { public const string ModManagerConfigPath = $"{ModCachePath}\\mod_manager_config.json"; public const string DllManagerConfigPath = $"{ModCachePath}\\dll_manager_config.json"; public const string PakDirMetadata = $"{ModCachePath}\\pak_dir_metadata.json"; + public const string ManagedPakInventoryFileName = ".unchained_managed_paks.json"; public static string Chiv2ConfigPath(string? savedDirSuffix) { var unexpandedPath = $@"%LOCALAPPDATA%\Chivalry 2\Saved_{savedDirSuffix}\Config\WindowsNoEditor"; diff --git a/UnchainedLauncher.Core/Utilities/MemoryProgress.cs b/UnchainedLauncher.Core/Utilities/MemoryProgress.cs index 69e8c626..ad1bbd88 100644 --- a/UnchainedLauncher.Core/Utilities/MemoryProgress.cs +++ b/UnchainedLauncher.Core/Utilities/MemoryProgress.cs @@ -1,60 +1,71 @@ -using PropertyChanged; -using System.Collections.ObjectModel; -using System.ComponentModel; - -namespace UnchainedLauncher.Core.Utilities { - [AddINotifyPropertyChangedInterface] - public partial class MemoryProgress : IProgress, INotifyPropertyChanged { - public double ProgressPercentage { get; protected set; } - public string TaskName { get; protected set; } - - public MemoryProgress(string? taskName = null, double progress = 0) { - TaskName = taskName ?? ""; - ProgressPercentage = progress; - } - - public void Report(double value) { - ProgressPercentage = Math.Clamp(value, 0.00, 100.00); - } - } - - public class AccumulatedMemoryProgress : MemoryProgress { - private readonly SynchronizationContext? _synchronizationContext; - - // average the progress of sub-percentages - private double CalcAggregatePercentage() => - !Progresses.Any() - ? 100.00 - : Progresses.ToList() - .Sum(mp => mp.ProgressPercentage) - / Progresses.Count; - - public ObservableCollection Progresses { get; private set; } - - public AccumulatedMemoryProgress(IEnumerable? memoryProgress = null, string? taskName = null, SynchronizationContext? synchronizationContext = null) : base(taskName) { - _synchronizationContext = synchronizationContext ?? SynchronizationContext.Current ?? UISynchronizationContext.Context; - Progresses = new ObservableCollection(memoryProgress ?? new List()); - Progresses.ToList().ForEach(BindTo); - ProgressPercentage = CalcAggregatePercentage(); - } - - public void AlsoTrack(MemoryProgress memoryProgress) { - _synchronizationContext.Post(_ => { - Progresses.Add(memoryProgress); - ProgressPercentage = CalcAggregatePercentage(); - }, null); - - BindTo(memoryProgress); - } - - private void OnProgressPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(ProgressPercentage)) { - ProgressPercentage = CalcAggregatePercentage(); - } - } - - private void BindTo(MemoryProgress mp) { - mp.PropertyChanged += OnProgressPropertyChanged; - } - } +using PropertyChanged; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace UnchainedLauncher.Core.Utilities { + [AddINotifyPropertyChangedInterface] + public partial class MemoryProgress : IProgress, INotifyPropertyChanged { + public double ProgressPercentage { get; protected set; } + public string TaskName { get; protected set; } + + public MemoryProgress(string? taskName = null, double progress = 0) { + TaskName = taskName ?? ""; + ProgressPercentage = progress; + } + + public void Report(double value) { + ProgressPercentage = Math.Clamp(value, 0.00, 100.00); + } + } + + public class AccumulatedMemoryProgress : MemoryProgress { + private readonly SynchronizationContext? _synchronizationContext; + private readonly object _progressLock = new(); + + // average the progress of sub-percentages + private double CalcAggregatePercentage() { + lock (_progressLock) { + return Progresses.Count == 0 + ? 100.00 + : Progresses.Sum(mp => mp.ProgressPercentage) / Progresses.Count; + } + } + + public ObservableCollection Progresses { get; private set; } + + public AccumulatedMemoryProgress(IEnumerable? memoryProgress = null, string? taskName = null, SynchronizationContext? synchronizationContext = null) : base(taskName) { + _synchronizationContext = synchronizationContext ?? SynchronizationContext.Current ?? UISynchronizationContext.Context; + Progresses = new ObservableCollection(memoryProgress ?? new List()); + Progresses.ToList().ForEach(BindTo); + ProgressPercentage = CalcAggregatePercentage(); + } + + public void AlsoTrack(MemoryProgress memoryProgress) { + void AddProgress() { + lock (_progressLock) { + Progresses.Add(memoryProgress); + ProgressPercentage = CalcAggregatePercentage(); + } + } + + // Console hosts have no UI SynchronizationContext. Keep the UI's + // existing dispatch behavior, but track synchronously when headless. + if (_synchronizationContext is null) AddProgress(); + else _synchronizationContext.Post(_ => AddProgress(), null); + + BindTo(memoryProgress); + } + + private void OnProgressPropertyChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName == nameof(ProgressPercentage)) { + lock (_progressLock) { + ProgressPercentage = CalcAggregatePercentage(); + } + } + } + + private void BindTo(MemoryProgress mp) { + mp.PropertyChanged += OnProgressPropertyChanged; + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs index 425301b8..57ea00ba 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs @@ -7,7 +7,7 @@ namespace UnchainedLauncher.GUI.Tests.Unit { public sealed class DllManagerVMTests { [Fact] public async Task RemovedPinnedVersionShowsUnavailableEvenWhenOtherVersionsRemainRegistered() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "removed-version-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "removed-version-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { Release("Plugin") }) }; @@ -42,7 +42,7 @@ public async Task RemovedPinnedVersionShowsUnavailableEvenWhenOtherVersionsRemai [InlineData(true, false)] [InlineData(true, true)] public async Task RemovedSelectionsRemainVisibleAndCanBeDisabledAfterRefreshOrRestart(bool removeRegistry, bool restart) { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "removed-plugin-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "removed-plugin-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var release = Release("RemovedPlugin"); @@ -200,7 +200,7 @@ public async Task OneCardPerPluginAndEnableDisableChangesOnlyThatPlugin() { [Fact] public async Task ModernPackageCardsShowReleaseSpecificTargetsAssetsAndNotes() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-package-view-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-package-view-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var shared = new PluginInfo("Core", "Description", ["Author"], Target: "shared"); @@ -262,7 +262,7 @@ public bool AllowUnstablePluginReleases { [Fact] public async Task OriginalSettingsDriveDllControlsAndPrereleaseVisibility() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-settings-view-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-settings-view-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var releases = new[] { "1.0.0", "2.0.0-RC1" }.Select(version => new DllRelease("Chiv2-Community", "UnchainedPlugin", version, @@ -351,7 +351,7 @@ protected override Task SendAsync(HttpRequestMessage reques [Fact] public async Task PluginLinksOpenRepositoryAndOriginalDisplayedTagWithoutChangingSelection() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "plugin-links-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "plugin-links-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { @@ -396,7 +396,7 @@ private static DllRelease Release(string name, string version = "1.0.0", string [Fact] public async Task PluginSearchAndSharedSortModesRebuildWithoutChangingSelections() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "plugin-sort-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "plugin-sort-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { @@ -452,7 +452,7 @@ public async Task PluginSearchAndSharedSortModesRebuildWithoutChangingSelections [InlineData(true, false)] [InlineData(true, true)] public async Task UpdatePluginsConfirmsEligibleVisibleEnabledVersionsWithoutDownloading(bool unstable, bool approve) { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "plugin-update-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "plugin-update-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { diff --git a/UnchainedLauncher.GUI.Tests.Unit/HelpVMPlatformStubs.cs b/UnchainedLauncher.GUI.Tests.Unit/HelpVMPlatformStubs.cs new file mode 100644 index 00000000..03207247 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/HelpVMPlatformStubs.cs @@ -0,0 +1,13 @@ +// Unused update-path dependencies let the real HelpVM commands run without WPF. +namespace UnchainedLauncher.GUI.ViewModels { + public sealed class SettingsVM { + public Semver.SemVersion Version => throw new NotSupportedException(); + public string CurrentVersion => throw new NotSupportedException(); + } +} + +namespace System.Windows { + public static class MessageBox { + public static void Show(string message) => throw new NotSupportedException(); + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs new file mode 100644 index 00000000..3640c3cf --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.GUI.ViewModels; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit; + +public sealed class HelpVMTests { + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CleanupConfirmationsDescribeActualPathsAndRetainedAssets(bool uninstall) { + var paks = DispatchProxy.Create(); + var dialogue = new RejectingDialogue(); + var vm = new HelpVM(null!, null!, null!, paks, dialogue, + _ => throw new InvalidOperationException("Must not exit after rejecting confirmation.")); + + foreach (var path in new[] { Path.Combine("confirmation-only", "Saved_unchained", "Paks"), "changed\nprofile\\[unknown]*\rPaks" }) { + ((ReadOnlyPakDir)paks).Path = path; + if (uninstall) vm.UninstallLauncherCommand.Execute(null); + else vm.CleanUpInstallationCommand.Execute(null); + + var intro = uninstall + ? "Are you sure? This will disable all mods, reset all settings to their defaults, and uninstall the launcher. This will delete the following:" + : "Are you sure? This will disable all mods and reset all settings to their defaults. This will delete the following:"; + var instruction = !uninstall ? "After deleting, the launcher will restart itself." + : File.Exists(FilePaths.OriginalLauncherPath) ? "IMPORTANT: Wait at least 1 second and then launch normally." + : "IMPORTANT: The original launcher is not present. You will need to verify game files after this so it can be re-downloaded."; + Assert.Equal(string.Join("\n", intro, "* All files in .mod_cache", + $"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}", + $"* All non-vanilla files in {path}", + "Shared caches, other CLI profiles, and legacy installation-wide assets are retained.", "", instruction), dialogue.Messages[^1]); + Assert.Equal(uninstall ? "Really uninstall?" : "Really clean up installation?", dialogue.Captions[^1]); + } + Assert.Equal(2, dialogue.Messages.Count); + } + + public class ReadOnlyPakDir : DispatchProxy { + public string Path { get; set; } = ""; + protected override object? Invoke(MethodInfo? method, object?[]? args) => + method?.Name == "get_DirPath" + ? Path + : throw new InvalidOperationException($"Unexpected pak operation: {method?.Name}"); + } + + private sealed class RejectingDialogue : IUserDialogueSpawner { + public List Messages { get; } = []; + public List Captions { get; } = []; + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) { + Messages.Add(message); + Captions.Add(caption); + return UserDialogueChoice.No; + } + public void DisplayMessage(string message) => throw new NotSupportedException(); + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, + string noButtonText, string? cancelButtonText, IEnumerable updates) => throw new NotSupportedException(); + public Action DisplayProgress(MemoryProgress progress) => throw new NotSupportedException(); + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs new file mode 100644 index 00000000..1ae8e62e --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs @@ -0,0 +1,634 @@ +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using System.Diagnostics; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.GUI.Services; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit; + +public sealed class ProfileModsLaunchPreparerTests { + [Fact] + public async Task VanillaLaunchOnlyUnsignsInstallationEvenWhileProfileIsLeased() { + var root = Scratch("gui-vanilla-boundary-"); + try { + var installationPaks = Path.Combine(root, FilePaths.PakDir); + Directory.CreateDirectory(installationPaks); + var baseSig = Path.Combine(installationPaks, "pakchunk0-WindowsNoEditor.sig"); + var legacySig = Path.Combine(installationPaks, "legacy.sig"); + File.WriteAllText(baseSig, "base signature"); + File.WriteAllText(legacySig, "legacy signature"); + File.WriteAllText(Path.ChangeExtension(legacySig, ".pak"), "legacy payload"); + var assets = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, root); + Directory.CreateDirectory(assets.Paks); + File.WriteAllText(Path.Combine(assets.Paks, "unknown.sig"), "profile signature"); + File.WriteAllText(Path.Combine(assets.Paks, "unknown.pak"), "profile payload"); + File.WriteAllText(Path.Combine(assets.Paks, FilePaths.ManagedPakInventoryFileName), "profile inventory"); + var before = Directory.GetFiles(assets.Paks).ToDictionary(path => path, File.ReadAllBytes); + using var held = assets.AcquireLease(); + var dialogue = new Dialogue(); + var process = new CapturingProcessLauncher(); + var launcher = new Chivalry2Launcher(NoSigPreparer.CreateForInstallation(root, dialogue) + .IgnoreOptions(), process, root); + + Assert.True((await launcher.Launch(Options(null))).IsLeft); + Assert.Equal(1, process.Calls); + Assert.False(File.Exists(legacySig)); + Assert.Equal("legacy payload", File.ReadAllText(Path.ChangeExtension(legacySig, ".pak"))); + Assert.Equal("base signature", File.ReadAllText(baseSig)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(assets.Paks).Order()); + foreach (var pair in before) Assert.Equal(pair.Value, File.ReadAllBytes(pair.Key)); + Assert.Empty(dialogue.Messages); + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData(null, true)] + [InlineData("AAA", true)] + [InlineData("AAA", false)] + [InlineData("zzz", true)] + [InlineData("zzz", false)] + public async Task CombinedPipelineLocksBothProfilesBeforeEnteringRealDllPreparer(string? suffix, bool holdShared) { + var root = Scratch("gui-combined-held-"); + try { + var shared = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, root); + var selected = new ProfileAssetPaths(suffix ?? ProfileAssetPaths.SharedSuffix, root); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), shared.CreatePakDir(root), []); + Directory.CreateDirectory(selected.Paks); + File.WriteAllText(Path.Combine(selected.Paks, "unknown.sig"), "profile signature"); + var dialogue = new Dialogue(); + using var http = new HttpClient(); + var (dll, active) = CachedDll(shared, http); + var entered = 0; + var preceding = new CallbackPreparer(async options => { + entered++; + return await new DllLaunchPreparer(dll, dialogue).PrepareLaunch(options); + }); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root, preceding); + var heldAssets = holdShared ? shared : selected; + using (heldAssets.AcquireLease()) { + var before = Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".instance.lock")).ToDictionary(path => path, File.ReadAllBytes); + var process = new CapturingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(preparer, process, root, new NullInjector()); + Assert.True((await launcher.Launch(Options(suffix))).IsLeft); + Assert.Equal(0, process.Calls); + Assert.Equal(0, entered); + Assert.False(File.Exists(active)); + Assert.Contains("another launcher", Assert.Single(dialogue.Messages)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".instance.lock")).Order()); + foreach (var pair in before) Assert.Equal(pair.Value, File.ReadAllBytes(pair.Key)); + // The lower-sorted profile is acquired first, regardless of which one holds DLLs. + if (shared.Root != selected.Root) { + var first = new[] { shared, selected }.OrderBy(p => p.Root, StringComparer.OrdinalIgnoreCase).First(); + var second = first == shared ? selected : shared; + if (heldAssets == second) { + Assert.True(File.Exists(Path.Combine(first.Root, ".instance.lock"))); + using (first.AcquireLease()) { } + } + else Assert.False(File.Exists(Path.Combine(second.Root, ".instance.lock"))); + } + } + Assert.True((await preparer.PrepareLaunch(Options(suffix))).IsSome); + Assert.Equal(1, entered); + Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(active)); + Assert.Empty(ReadInventory(selected.Paks)); + Assert.Equal("profile signature", File.ReadAllText(Path.Combine(selected.Paks, "unknown.sig"))); + using (shared.AcquireLease()) { } + using (selected.AcquireLease()) { } + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData(null, "success")] + [InlineData("un-chained", "success")] + [InlineData("Other", "success")] + [InlineData("Other", "cancel")] + [InlineData("Other", "error")] + [InlineData("Other", "throw-cancel")] + public async Task CombinedPipelineHoldsLeasesAcrossAsyncDllPreparationAndRebindsAfterSelection(string? suffix, string outcome) { + var root = Scratch("gui-combined-async-"); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task>? pending = null; + try { + var shared = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, root); + var selected = new ProfileAssetPaths(suffix ?? ProfileAssetPaths.SharedSuffix, root); + var installationPaks = Path.Combine(root, FilePaths.PakDir); + Directory.CreateDirectory(installationPaks); + File.WriteAllText(Path.Combine(installationPaks, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var payload = Path.Combine(root, "source.pak"); + File.WriteAllText(payload, "pak payload"); + var hash = FileHelpers.Sha512(payload).Match(value => value, error => throw new Exception(error.Message)); + var release = new Release("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo("https://github.com/org/mod", "mod", "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + var registry = new LocalModRegistry(Path.Combine(root, "registry")); + await registry.AddRelease(release, payload); + var manager = new ModManager(registry, shared.CreatePakDir(root), []); + Assert.False((await manager.UpdateModsList()).HasErrors); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dialogue = new Dialogue(); + using var http = new HttpClient(); + var (dll, active) = CachedDll(shared, http); + Exception error = outcome == "throw-cancel" ? new OperationCanceledException("original cancellation") + : new InvalidOperationException("original DLL error"); + dialogue.ProgressStarted = () => { + Assert.True(File.Exists(active)); + Assert.Throws(() => shared.AcquireLease()); + Assert.Throws(() => selected.AcquireLease()); + }; + var preceding = new CallbackPreparer(async options => { + Assert.Empty(Directory.GetFiles(selected.Paks, "*.pak")); + started.SetResult(); + await finish.Task; + if (outcome is "error" or "throw-cancel") throw error; + if (outcome == "cancel") return Option.None; + Assert.True(manager.EnableModRelease(ReleaseCoordinates.FromRelease(release))); + return await new DllLaunchPreparer(dll, dialogue).PrepareLaunch(options); + }); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root, preceding); + pending = preparer.PrepareLaunch(Options(suffix)); + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Throws(() => shared.AcquireLease()); + Assert.Throws(() => selected.AcquireLease()); + Assert.False(File.Exists(active)); + finish.SetResult(); + if (outcome is "error" or "throw-cancel") Assert.Same(error, await Assert.ThrowsAnyAsync(() => pending)); + else Assert.Equal(outcome == "success", (await pending.WaitAsync(TimeSpan.FromSeconds(10))).IsSome); + using (shared.AcquireLease()) { } + using (selected.AcquireLease()) { } + if (outcome == "success") { + Assert.True(File.Exists(active)); + Assert.Equal(ReleaseCoordinates.FromRelease(release), Assert.Single(ReadInventory(selected.Paks)).Coordinates); + Assert.Single(manager.EnabledModReleaseCoordinates); + } + else { + Assert.False(File.Exists(active)); + Assert.Empty(Directory.GetFiles(selected.Paks)); + } + Assert.Empty(dialogue.Messages); + } + finally { + finish.TrySetResult(); + if (pending is not null) try { await pending; } catch { } + Directory.Delete(root, true); + } + } + + private static (UnchainedLauncher.Core.Services.Dlls.DllManager Manager, string Active) CachedDll(ProfileAssetPaths shared, HttpClient http) { + var release = new UnchainedLauncher.Core.Services.Dlls.DllRelease("org", "plugin", "1.0.0", + "https://example.test/plugin.dll", Convert.ToHexString(System.Security.Cryptography.SHA256.HashData([1, 2, 3])).ToLowerInvariant(), 3); + var cache = Path.Combine(shared.DllCache, release.Sha256, "plugin.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + File.WriteAllBytes(cache, [1, 2, 3]); + return (UnchainedLauncher.Core.Services.Dlls.DllManager.CreateForLaunch(shared.Plugins, [release], [release], http, () => [], shared.DllCache), + Path.Combine(shared.Plugins, "Managed", "org", "plugin", "plugin.dll")); + } + + private sealed class CallbackPreparer(Func>> prepare) : IChivalry2LaunchPreparer { + public Task> PrepareLaunch(LaunchOptions options) => prepare(options); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RebindingCopiesOwnershipOnlyWithinTheSameProfileAndSnapshotsSelection(bool sameProfile) { + var root = Scratch("gui-rebind-"); + try { + var source = new ProfileAssetPaths("Source", root); + var target = sameProfile ? new ProfileAssetPaths("Source", root) : new ProfileAssetPaths("Target", root); + Directory.CreateDirectory(source.Paks); + Directory.CreateDirectory(target.Paks); + var coordinates = new ReleaseCoordinates("org", "mod", "v1.0.0"); + var owned = new ManagedPak(coordinates, "mod_0_P.pak", 0); + File.WriteAllText(Path.Combine(source.Paks, owned.PakFileName), "source"); + if (!sameProfile) File.WriteAllText(Path.Combine(target.Paks, owned.PakFileName), "unknown target payload"); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), + source.CreatePakDir(root, [owned]), [coordinates]); + var rebound = target.Rebind(manager, root); + Assert.NotSame(manager, rebound); + Assert.NotSame(manager.PakDir, rebound.PakDir); + Assert.Same(manager.ModRegistry, rebound.ModRegistry); + Assert.Equal(new[] { coordinates }, rebound.EnabledModReleaseCoordinates); + Assert.Equal(sameProfile ? new[] { owned } : [], Assert.IsType(rebound.PakDir).ManagedPaks); + Assert.Single(Assert.IsType(manager.PakDir).ManagedPaks); + Assert.IsType>(manager.EnabledModReleaseCoordinates).Clear(); + Assert.Single(rebound.EnabledModReleaseCoordinates); + if (!sameProfile) Assert.Equal("unknown target payload", File.ReadAllText(Path.Combine(target.Paks, owned.PakFileName))); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Theory] + [InlineData(null, "unchained")] + [InlineData("Courtyard", "Courtyard")] + [InlineData("Court_yard_1", "Court_yard_1")] + [InlineData("Court-yard", "Courtyard")] + public async Task LaunchSuffixSelectsInstalledAndSignedPaks(string? suffix, string expectedSuffix) { + var root = Scratch("gui-profile-"); + Directory.CreateDirectory(root); + try { + var installation = Path.Combine(root, "game"); + var defaultPaks = Path.Combine(installation, FilePaths.PakDir); + Directory.CreateDirectory(defaultPaks); + File.WriteAllText(Path.Combine(defaultPaks, "pakchunk0-WindowsNoEditor.sig"), "base signature"); + var payload = Path.Combine(root, "source.pak"); + File.WriteAllText(payload, "approved payload"); + var hash = FileHelpers.Sha512(payload).Match(Right: value => value, Left: e => throw new Exception(e.Message)); + Release Release(string name, params Dependency[] dependencies) => new("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo($"https://github.com/org/{name}", name, "test", null, null, [], [], dependencies.ToList()), + new AssetCollections([], [], [], [], []), null); + var dependency = Release("Dependency"); + var selected = Release("Selected", new Dependency("https://github.com/org/Dependency", "v1.0.0")); + var unselected = Release("Unselected"); + var registry = new LocalModRegistry(Path.Combine(root, "registry")); + foreach (var release in new[] { dependency, selected, unselected }) await registry.AddRelease(release, payload); + var shared = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, root); + var manager = new ModManager(registry, shared.CreatePakDir(installation), [ReleaseCoordinates.FromRelease(selected)]); + Assert.False((await manager.UpdateModsList()).HasErrors); + var dialogue = new Dialogue(); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, installation, root); + var profilePaks = new ProfileAssetPaths(expectedSuffix, root).Paks; + Directory.CreateDirectory(profilePaks); + var unknownSigs = new[] { "UserBackup.sig", "Selected_1_P.sig" }; + foreach (var name in unknownSigs) File.WriteAllText(Path.Combine(profilePaks, name), "unknown " + name); + var server = new ServerLaunchOptions(true, "Courtyard", "", false, Option.None, "/Game/Maps/Courtyard", + 7777, 7778, 7779, 7780, null, null, null, null, null, null, Option.None, [], + Option.None, false, false, CensorArg.None); + var options = new LaunchOptions(manager.EnabledModReleaseCoordinates, Option.None, "", false, false, + suffix is null ? Option.None : Option.Some(suffix), + suffix is null ? Option.None : Option.Some(server)); + + var result = await preparer.PrepareLaunch(options); + Assert.True(result.IsSome); + var prepared = result.ValueUnsafe(); + Assert.Equal(options.ServerLaunchOptions, prepared.ServerLaunchOptions); + Assert.Equal(expectedSuffix, prepared.SavedDirSuffix.ValueUnsafe()); + Assert.Contains(prepared.ToCLIArgs(), arg => arg.Rendered == "-saveddirsuffix=" + expectedSuffix); + var process = new CapturingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(preparer, process, installation, new NullInjector()); + Assert.True((await launcher.Launch(options)).IsLeft); // Stop at the OS process boundary. + Assert.Contains("-saveddirsuffix=" + expectedSuffix, process.Arguments); + Assert.DoesNotContain("--additional-pak-dir", process.Arguments); + var paks = Path.Combine(root, "Chivalry 2", "Saved_" + expectedSuffix, "Paks"); + var inventory = ReadInventory(paks); + Assert.Equal("Selected (1)_1_P.pak", Assert.Single(inventory, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(selected)).PakFileName); + Assert.True((await preparer.PrepareLaunch(options)).IsSome); + Assert.Equal(inventory, ReadInventory(paks)); + foreach (var name in unknownSigs) Assert.Equal("unknown " + name, File.ReadAllText(Path.Combine(paks, name))); + Assert.Equal(2, inventory.Length); + Assert.Contains(inventory, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(selected)); + Assert.Contains(inventory, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(dependency)); + foreach (var pak in inventory) { + Assert.Equal("approved payload", File.ReadAllText(Path.Combine(paks, pak.PakFileName))); + Assert.Equal("base signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig"))); + Assert.EndsWith($"_{pak.Priority}_P.pak", pak.PakFileName); + } + if (expectedSuffix != ProfileAssetPaths.SharedSuffix) Assert.Empty(Directory.GetFiles(shared.Paks, "*.pak")); + Assert.False(Directory.Exists(new ProfileAssetPaths(expectedSuffix, root).Plugins)); + + // The next launch must use current GUI selections and reconcile this profile's ownership. + Assert.True(manager.DisableModRelease(ReleaseCoordinates.FromRelease(selected))); + Assert.True(manager.EnableModRelease(ReleaseCoordinates.FromRelease(unselected))); + Assert.True((await preparer.PrepareLaunch(options with { EnabledReleases = manager.EnabledModReleaseCoordinates })).IsSome); + Assert.Equal(ReleaseCoordinates.FromRelease(unselected), Assert.Single(ReadInventory(paks)).Coordinates); + Assert.Single(Directory.GetFiles(paks, "*.pak")); + Assert.Equal(3, Directory.GetFiles(paks, "*.sig").Length); + foreach (var name in unknownSigs) Assert.Equal("unknown " + name, File.ReadAllText(Path.Combine(paks, name))); + Assert.Equal("approved payload", File.ReadAllText(Path.Combine(shared.PakCache, hash.ToLowerInvariant(), "mod.pak"))); + Assert.Empty(Directory.GetFiles(shared.PakCache, "*.sig", SearchOption.AllDirectories)); + Assert.Empty(dialogue.Messages); + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData("")] + [InlineData("---")] + [InlineData("../other")] + public async Task InvalidExplicitSuffixCancelsBeforeCreatingProfileAssets(string suffix) { + var root = Scratch("gui-invalid-profile-"); + try { + var registry = new LocalModRegistry(Path.Combine(root, "registry")); + var manager = new ModManager(registry, new PakDir(Path.Combine(root, "source"), []), []); + var before = Directory.GetFileSystemEntries(root, "*", SearchOption.AllDirectories).Order().ToArray(); + var dialogue = new Dialogue(); + var entered = false; + var preceding = new CallbackPreparer(options => { + entered = true; + return Task.FromResult(Option.Some(options)); + }); + var result = await new ProfileModsLaunchPreparer(manager, dialogue, root, root, preceding).PrepareLaunch(Options(suffix)); + Assert.False(entered); + Assert.True(result.IsNone); + Assert.Contains("Invalid saved directory suffix", Assert.Single(dialogue.Messages)); + Assert.Equal(before, Directory.GetFileSystemEntries(root, "*", SearchOption.AllDirectories).Order().ToArray()); + } + finally { Directory.Delete(root, true); } + } + + [Fact] + public async Task HeldCliLeaseCancelsGuiPreparationWithoutReconcilingOwnedFiles() { + var root = Scratch("gui-cli-lease-"); + try { + var assets = new ProfileAssetPaths("unchained", root); + var coordinates = new ReleaseCoordinates("org", "owned", "v1.0.0"); + var owned = new ManagedPak(coordinates, "owned_0_P.pak", 0); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), assets.CreatePakDir(root), []); + var payload = Path.Combine(assets.Paks, owned.PakFileName); + File.WriteAllText(payload, "owned payload"); + File.WriteAllText(Path.ChangeExtension(payload, ".sig"), "owned signature"); + File.WriteAllText(Path.Combine(assets.Paks, FilePaths.ManagedPakInventoryFileName), + JsonHelpers.Serialize(new PakDirMetadata(assets.Paks, [owned]))); + // Exact CLI convention, independently opened rather than a second test-only lock. + Directory.CreateDirectory(assets.Root); + using var lease = new FileStream(Path.Combine(assets.Root, ".instance.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + var before = Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".instance.lock")).ToDictionary(path => path, File.ReadAllText); + var dialogue = new Dialogue(); + Assert.True((await new ProfileModsLaunchPreparer(manager, dialogue, root, root).PrepareLaunch(Options(null))).IsNone); + Assert.Contains("another launcher", Assert.Single(dialogue.Messages)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".instance.lock")).Order()); + foreach (var pair in before) Assert.Equal(pair.Value, File.ReadAllText(pair.Key)); + } + finally { Directory.Delete(root, true); } + } + + [Fact] + public async Task PendingGuiTransactionRejectsSameProfileButAllowsAnotherProfile() { + var root = Scratch("gui-concurrent-profile-"); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try { + var defaultPaks = Path.Combine(root, FilePaths.PakDir); + Directory.CreateDirectory(defaultPaks); + File.WriteAllText(Path.Combine(defaultPaks, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var payload = Path.Combine(root, "source.pak"); + File.WriteAllText(payload, "payload"); + var hash = FileHelpers.Sha512(payload).Match(Right: value => value, Left: e => throw new Exception(e.Message)); + var release = new Release("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo("https://github.com/org/mod", "mod", "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + var local = new LocalModRegistry(Path.Combine(root, "registry")); + await local.AddRelease(release, payload); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registry = new PausedRegistry(local, started, finish); + var source = new ProfileAssetPaths("Source", root); + var manager = new ModManager(registry, source.CreatePakDir(root), [ReleaseCoordinates.FromRelease(release)]); + Assert.False((await manager.UpdateModsList()).HasErrors); + var firstDialogue = new Dialogue(); + var preparer = new ProfileModsLaunchPreparer(manager, firstDialogue, root, root); + var first = preparer.PrepareLaunch(Options("First")); + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var firstAssets = new ProfileAssetPaths("First", root); + var before = Directory.GetFiles(firstAssets.Paks).ToDictionary(path => path, File.ReadAllBytes); + Assert.True((await preparer.PrepareLaunch(Options("First")).WaitAsync(TimeSpan.FromSeconds(10))).IsNone); + Assert.Contains("another launcher", Assert.Single(firstDialogue.Messages)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(firstAssets.Paks).Order()); + foreach (var pair in before) Assert.Equal(pair.Value, File.ReadAllBytes(pair.Key)); + // An empty selection avoids waiting on the intentionally shared immutable cache object. + var otherManager = new ModManager(local, source.CreatePakDir(root), []); + var otherDialogue = new Dialogue(); + Assert.True((await new ProfileModsLaunchPreparer(otherManager, otherDialogue, root, root) + .PrepareLaunch(Options("Other"))).IsSome); + Assert.False(first.IsCompleted); + Assert.Empty(otherDialogue.Messages); + finish.SetResult(); + Assert.True((await first.WaitAsync(TimeSpan.FromSeconds(10))).IsSome); + Assert.Single(ReadInventory(firstAssets.Paks)); + Assert.True((await preparer.PrepareLaunch(Options("First"))).IsSome); + } + finally { finish.TrySetResult(); Directory.Delete(root, true); } + } + + [Theory] + [InlineData(null)] + [InlineData("Target")] + public async Task ObstructedProfilePaksCancelsLaunchAndAllowsRetry(string? suffix) { + var root = Scratch("gui-obstructed-profile-"); + try { + var assets = new ProfileAssetPaths(suffix ?? ProfileAssetPaths.SharedSuffix, root); + var source = new ProfileAssetPaths("Source", root); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), source.CreatePakDir(root), []); + Directory.CreateDirectory(Path.GetDirectoryName(assets.Paks)!); + byte[] obstruction = [0, 1, 255, 42]; + File.WriteAllBytes(assets.Paks, obstruction); + var dialogue = new Dialogue(); + var entered = 0; + var preceding = new CallbackPreparer(options => { + entered++; + return Task.FromResult(Option.Some(options)); + }); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root, preceding); + var process = new CapturingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(preparer, process, root, new NullInjector()); + + Assert.True((await preparer.PrepareLaunch(Options(suffix))).IsNone); + Assert.True((await launcher.Launch(Options(suffix))).IsLeft); + Assert.Equal(0, process.Calls); + Assert.Equal(0, entered); + Assert.Equal(obstruction, File.ReadAllBytes(assets.Paks)); + Assert.Equal(2, dialogue.Messages.Count); + Assert.All(dialogue.Messages, message => { + Assert.Contains("Could not prepare profile assets", message); + Assert.Contains("Launch cancelled", message); + }); + Assert.Equal(0, dialogue.ProgressClosed); + using (assets.AcquireLease()) { } + + File.Delete(assets.Paks); + dialogue.Messages.Clear(); + Assert.True((await preparer.PrepareLaunch(Options(suffix))).IsSome); + Assert.True((await launcher.Launch(Options(suffix))).IsLeft); // Stop at the OS process boundary. + Assert.Equal(1, process.Calls); + Assert.Equal(2, entered); + Assert.Empty(dialogue.Messages); + Assert.Equal(2, dialogue.ProgressClosed); + using (assets.AcquireLease()) { } + } + finally { Directory.Delete(root, true); } + } + + private static string Scratch(string prefix) => Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? + (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), prefix + Guid.NewGuid().ToString("N")); + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task InventoryIoFailureCancelsLaunchAndReleasesLease(bool gui, bool lockedRead) { + var root = Scratch("gui-inventory-io-"); + try { + var assets = new ProfileAssetPaths("unchained", root); + var basePaks = Path.Combine(root, FilePaths.PakDir); + Directory.CreateDirectory(basePaks); + File.WriteAllText(Path.Combine(basePaks, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var payload = Path.Combine(root, "source.pak"); + File.WriteAllText(payload, "payload"); + var hash = FileHelpers.Sha512(payload).Match(value => value, error => throw new Exception(error.Message)); + var release = new Release("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo("https://github.com/org/mod", "mod", "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + var registry = new LocalModRegistry(Path.Combine(root, "registry")); + await registry.AddRelease(release, payload); + var manager = new ModManager(registry, assets.CreatePakDir(root), [ReleaseCoordinates.FromRelease(release)]); + Assert.False((await manager.UpdateModsList()).HasErrors); + var path = Path.Combine(assets.Paks, FilePaths.ManagedPakInventoryFileName); + File.WriteAllText(path, "{\"ManagedPaks\":[]}"); + if (!lockedRead) Directory.CreateDirectory(path + ".tmp"); + var dialogue = new Dialogue(); + IChivalry2LaunchPreparer preparer = gui + ? new ProfileModsLaunchPreparer(manager, dialogue, root, root) + : ((IChivalry2LaunchPreparer)new Chivalry2ModsInstaller(manager, dialogue)) + .Sub(SigPreparer.CreateForProfile(manager.PakDir, dialogue)); + using (var held = lockedRead ? new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None) : null) { + var process = new CapturingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(preparer, process, root, new NullInjector()); + Assert.True((await launcher.Launch(Options(null))).IsLeft); + Assert.Equal("", process.Arguments); + Assert.Contains("Could not reconcile managed pak inventory", Assert.Single(dialogue.Messages)); + Assert.Contains("Launch cancelled", dialogue.Messages[0]); + Assert.Equal(lockedRead ? 0 : 1, Directory.GetFiles(assets.Paks, "*.pak").Length); + // Installation itself signs downloads before the final inventory save. + Assert.Equal(lockedRead ? 0 : 1, Directory.GetFiles(assets.Paks, "*.sig").Length); + Assert.Equal(lockedRead ? 0 : 1, dialogue.ProgressClosed); + using var available = assets.AcquireLease(); + } + Assert.Equal("{\"ManagedPaks\":[]}", File.ReadAllText(path)); + if (!lockedRead) Directory.Delete(path + ".tmp"); + Assert.True((await preparer.PrepareLaunch(Options(null))).IsSome); + Assert.Single(ReadInventory(assets.Paks)); + // A failed save leaves the first download unowned, so retry must preserve it. + Assert.Equal(lockedRead ? 1 : 2, Directory.GetFiles(assets.Paks, "*.sig").Length); + } + finally { Directory.Delete(root, true); } + } + + private static LaunchOptions Options(string? suffix) => new([], Option.None, "", false, false, + suffix is null ? Option.None : Option.Some(suffix), Option.None); + + [Fact] + public async Task EmptyProfilePreparationPreservesUnknownSignatures() { + var root = Scratch("gui-empty-signatures-"); + try { + var assets = new ProfileAssetPaths("unchained", root); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), assets.CreatePakDir(root), []); + var path = Path.Combine(assets.Paks, "UserBackup.sig"); + File.WriteAllBytes(path, [0, 1, 255, 42]); + var preparer = new ProfileModsLaunchPreparer(manager, new Dialogue(), root, root); + for (var i = 0; i < 2; i++) { + Assert.True((await preparer.PrepareLaunch(Options(null))).IsSome); + Assert.Equal(new byte[] { 0, 1, 255, 42 }, File.ReadAllBytes(path)); + Assert.Empty(ReadInventory(assets.Paks)); + } + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProfileSigningPreservesLegacySweepContractAndFailsClosed(bool profile) { + var root = Scratch("signing-contract-"); + try { + Directory.CreateDirectory(root); + var orphan = Path.Combine(root, "UserBackup.sig"); + File.WriteAllText(orphan, "unknown signature"); + var paks = new PakDir(root, []); + var dialogue = new Dialogue(); + var preparer = profile ? SigPreparer.CreateForProfile(paks, dialogue) : SigPreparer.Create(paks, dialogue); + Assert.True((await preparer.PrepareLaunch(LanguageExt.Unit.Default)).IsSome); + Assert.Equal(profile, File.Exists(orphan)); + File.WriteAllText(Path.Combine(root, "unknown.pak"), "payload"); + Assert.True((await preparer.PrepareLaunch(LanguageExt.Unit.Default)).IsNone); + Assert.Contains("errors while installing", Assert.Single(dialogue.Messages)); + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task PreparationExceptionsStillEscapeAfterInventorySaveAttempt(bool cancelled, bool saveBlocked) { + var root = Scratch("inventory-original-error-"); + try { + var assets = new ProfileAssetPaths("unchained", root); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), assets.CreatePakDir(root), []); + var path = Path.Combine(assets.Paks, FilePaths.ManagedPakInventoryFileName); + if (saveBlocked) Directory.CreateDirectory(path + ".tmp"); + Exception error = cancelled ? new OperationCanceledException("original") : new InvalidOperationException("original"); + var dialogue = new Dialogue { ProgressError = error }; + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root); + Assert.Same(error, await Assert.ThrowsAnyAsync(() => preparer.PrepareLaunch(Options(null)))); + using var available = assets.AcquireLease(); + if (saveBlocked) Assert.Contains("Launch cancelled", Assert.Single(dialogue.Messages)); + else { + Assert.Empty(dialogue.Messages); + Assert.Empty(ReadInventory(assets.Paks)); + } + } + finally { Directory.Delete(root, true); } + } + + private sealed class PausedRegistry(IModRegistry inner, TaskCompletionSource started, TaskCompletionSource finish) : IModRegistry { + public string Name => inner.Name; + public Task GetAllMods() => inner.GetAllMods(); + public EitherAsync GetMod(ModIdentifier id) => inner.GetMod(id); + public EitherAsync GetModRelease(ReleaseCoordinates coordinates) => inner.GetModRelease(coordinates); + public Task GetPakSize(Release release, CancellationToken cancellationToken = default) => inner.GetPakSize(release, cancellationToken); + public EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) => + Download(coordinates, outputLocation).ToAsync(); + private async Task> Download(ReleaseCoordinates coordinates, string outputLocation) { + started.TrySetResult(); + await finish.Task; + return await inner.DownloadPak(coordinates, outputLocation).ToEither(); + } + } + + private static ManagedPak[] ReadInventory(string paks) { + var result = JsonHelpers.Deserialize(File.ReadAllText(Path.Combine(paks, FilePaths.ManagedPakInventoryFileName))); + Assert.True(result.Success); + return result.Result!.ManagedPaks.ToArray(); + } + + private sealed class Dialogue : IUserDialogueSpawner { + public List Messages { get; } = []; + public int ProgressClosed { get; private set; } + public Exception? ProgressError { get; init; } + public Action? ProgressStarted { get; set; } + public void DisplayMessage(string message) => Messages.Add(message); + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) => throw new NotSupportedException(); + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, + string noButtonText, string? cancelButtonText, IEnumerable updates) => throw new NotSupportedException(); + public Action DisplayProgress(MemoryProgress progress) { + ProgressStarted?.Invoke(); + if (ProgressError is not null) throw ProgressError; + return () => { ProgressClosed++; }; + } + } + + private sealed class CapturingProcessLauncher : IProcessLauncher { + public string Executable => "test-game.exe"; + public string Arguments { get; private set; } = ""; + public int Calls { get; private set; } + public Either Launch(string workingDirectory, string args) { + Calls++; + Arguments = args; + return new LaunchFailed(Executable, args, LanguageExt.Common.Error.New("Stopped at the OS boundary.")); + } + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs index fed6b994..06680a87 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs @@ -13,7 +13,7 @@ public sealed class RegistryReleaseFormVMTests { [InlineData(true, false)] [InlineData(true, true)] public void EditAndNewReleasePreservePluginAndPakRequirements(bool newRelease, bool modernPakDependencies) { - var path = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "form-test-" + Guid.NewGuid().ToString("N") + ".pak"); + var path = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "form-test-" + Guid.NewGuid().ToString("N") + ".pak"); File.WriteAllText(path, "pak fixture"); try { var pak = new Dependency("https://github.com/Org/Pak", ">=1.0.0"); diff --git a/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj index b857599c..9d98826a 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj +++ b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj @@ -7,7 +7,9 @@ + + diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 7777a0f6..6ed299d7 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -37,10 +37,13 @@ namespace UnchainedLauncher.GUI { /// public partial class App : Application { private static readonly ILog _log = LogManager.GetLogger(typeof(App)); + private bool _unsafeLoadAllDlls; + private string[] _launchArguments = []; protected override async void OnStartup(StartupEventArgs e) { try { base.OnStartup(e); + (_unsafeLoadAllDlls, _launchArguments) = UnsafeDllLoading.ParseGuiArguments(e.Args); AppDomain.CurrentDomain.UnhandledException += (sender, args) => { @@ -140,11 +143,12 @@ select chiv2Installations.Path var modRegistry = InitializeModRegistry(FilePaths.RegistryConfigPath); var modManager = InitializeModManager(FilePaths.ModManagerConfigPath, modRegistry); + var assets = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix); var dllManager = new DllManager( - Path.Combine(Directory.GetCurrentDirectory(), FilePaths.PluginDir), + assets.Plugins, FilePaths.DllManagerConfigPath, new HttpClient(), () => modManager.GetEnabledAndDependencyReleases().ToArray(), () => modRegistry.ModRegistries.OfType() - .Select(registry => registry.DllDatabaseUrl).ToArray()); + .Select(registry => registry.DllDatabaseUrl).ToArray(), assets.DllCache); var registryWindowViewModel = new RegistryWindowVM(modRegistry, registryWindowService); @@ -169,13 +173,12 @@ select chiv2Installations.Path new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), FilePaths.GameBinPath)); #endif - var noSigLaunchPreparer = NoSigPreparer.Create(modManager.PakDir, userDialogueSpawner); - var sigLaunchPreparer = SigPreparer.Create(modManager.PakDir, userDialogueSpawner); + var noSigLaunchPreparer = NoSigPreparer.CreateForInstallation(Directory.GetCurrentDirectory(), userDialogueSpawner); IChivalry2LaunchPreparer pluginInstaller = new DllLaunchPreparer(dllManager, userDialogueSpawner); - IChivalry2LaunchPreparer modInstaller = new Chivalry2ModsInstaller( - modManager, userDialogueSpawner + IChivalry2LaunchPreparer modInstaller = new ProfileModsLaunchPreparer( + modManager, userDialogueSpawner, Directory.GetCurrentDirectory(), precedingDllPreparer: pluginInstaller ); var vanillaLauncher = new Chivalry2Launcher( @@ -185,16 +188,14 @@ select chiv2Installations.Path ); var unchainedLauncher = new UnchainedChivalry2Launcher( - pluginInstaller - .AndThen(modInstaller) - .Sub(sigLaunchPreparer), + modInstaller, unchainedProcessLauncher, Directory.GetCurrentDirectory(), #if DEBUG_FAKECHIVALRYLAUNCH // don't shove dlls into processes that don't need them and cause crashes new NullInjector(true) #else - new DllInjector(dllManager) + new DllInjector(dllManager, assets.Plugins, _unsafeLoadAllDlls) #endif ); @@ -263,8 +264,7 @@ private SettingsVM InitializeSettingsViewModel(IChivalry2InstallationFinder inst RegistryWindowVM registryWindowViewModel, RegistryWindowService registryWindowService, ModManager modManager, MessageBoxSpawner userDialogueSpawner) { var cliArgs = string.Join(" ", - Environment.GetCommandLineArgs() - .Skip(1) + _launchArguments .ToList() .Select(ArgumentEscaper.Escape) ); @@ -312,15 +312,17 @@ private AggregateModRegistry InitializeModRegistry(string jsonPath) { } private ModManager InitializeModManager(string jsonPath, IModRegistry registry) { + var assets = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix); Func initializeDefaultModManager = () => new ModManager( registry, - new PakDir(FilePaths.PakDir, Enumerable.Empty()), + assets.CreatePakDir(Directory.GetCurrentDirectory()), Enumerable.Empty() ); var codec = new ModManagerCodec(registry); var modManager = InitializeFromFileWithCodec(codec, jsonPath, initializeDefaultModManager); + modManager = assets.Rebind(modManager, Directory.GetCurrentDirectory()); RegisterSaveToFileOnExit(modManager, codec, jsonPath); return modManager; diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs new file mode 100644 index 00000000..515ac548 --- /dev/null +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -0,0 +1,65 @@ +using LanguageExt; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; + +namespace UnchainedLauncher.GUI.Services; + +public sealed class ProfileModsLaunchPreparer( + ModManager manager, IUserDialogueSpawner dialogue, string installation, string? localAppData = null, + IChivalry2LaunchPreparer? precedingDllPreparer = null) + : IChivalry2LaunchPreparer { + public async Task> PrepareLaunch(LaunchOptions options) { + ProfileAssetPaths assets; + try { + assets = new ProfileAssetPaths(options.SavedDirSuffix.IfNone(ProfileAssetPaths.SharedSuffix), localAppData); + } + catch (ArgumentException e) { + return Cancel(e); + } + var profiles = precedingDllPreparer is null ? new[] { assets } + : new[] { assets, new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, localAppData) }; + var leases = new List(); + try { + try { + foreach (var profile in profiles.DistinctBy(p => p.Root, StringComparer.OrdinalIgnoreCase) + .OrderBy(p => p.Root, StringComparer.OrdinalIgnoreCase)) + leases.Add(profile.AcquireLease()); + Directory.CreateDirectory(assets.Paks); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + return Cancel(e); + } + options = options with { SavedDirSuffix = assets.Suffix }; + if (precedingDllPreparer is not null) { + var prepared = await precedingDllPreparer.PrepareLaunch(options); + if (prepared.IsNone) return Option.None; + options = prepared.IfNone(options); + } + ModManager profileManager; + try { profileManager = assets.Rebind(manager, installation); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + return Cancel(e); + } + // DLL selection and injection stay bound to the shared manager and plugin directory. + IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(profileManager, dialogue); + return await installer + .Sub(SigPreparer.CreateForProfile(profileManager.PakDir, dialogue)) + .PrepareLaunch(options); + } + finally { + for (var i = leases.Count - 1; i >= 0; i--) leases[i].Dispose(); + } + } + + private Option Cancel(Exception error) { + dialogue.DisplayMessage($"Could not prepare profile assets: {error.Message}\nLaunch cancelled."); + return Option.None; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs index 1dfef641..20a6ff6b 100644 --- a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs @@ -80,9 +80,7 @@ private void UninstallLauncher() { var originalExists = File.Exists(originalPath); var message = new List() { "Are you sure? This will disable all mods, reset all settings to their defaults, and uninstall the launcher. This will delete the following:", - "* All files in .mod_cache", - "* All files in TBL\\Binaries\\Win64\\Plugins", - "* All non-vanilla paks in TBL\\Content\\Paks.", + CleanupDisclosure(), "", originalExists ? "IMPORTANT: Wait at least 1 second and then launch normally." @@ -147,9 +145,15 @@ private void UninstallLauncher() { ExitProgram(0); } + private string CleanupDisclosure() => + "* All files in .mod_cache\n" + + $"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}\n" + + $"* All non-vanilla files in {PakDir.DirPath}\n" + + "Shared caches, other CLI profiles, and legacy installation-wide assets are retained."; + private void CleanUpInstallation_actions() { FileHelpers.DeleteDirectory(FilePaths.ModCachePath); - FileHelpers.DeleteDirectory(FilePaths.PluginDir); + FileHelpers.DeleteDirectory(new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins); PakDir.Reset(); } @@ -158,9 +162,7 @@ private void CleanUpInstallation() { Logger.Info("CleanUpInstallation button clicked."); var message = new List() { "Are you sure? This will disable all mods and reset all settings to their defaults. This will delete the following:", - "* All files in .mod_cache", - "* All files in TBL\\Binaries\\Win64\\Plugins", - "* All non-vanilla paks in TBL\\Content\\Paks.", + CleanupDisclosure(), "", "After deleting, the launcher will restart itself." }.Aggregate((accumulator, next) => accumulator + "\n" + next); diff --git a/UnchainedLauncher.sln b/UnchainedLauncher.sln index 6ccf74bc..70d238d4 100644 --- a/UnchainedLauncher.sln +++ b/UnchainedLauncher.sln @@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnchainedLauncher.UnrealMod EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnchainedLauncher.PakScannerApp.Tests", "UnchainedLauncher.PakScannerApp.Tests\UnchainedLauncher.PakScannerApp.Tests.csproj", "{6F9E5F8B-AB88-4A12-AAB8-ABAF5E4BBABA}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnchainedLauncher.CLI", "UnchainedLauncher.CLI\UnchainedLauncher.CLI.csproj", "{08148C0B-891C-4573-B825-9682EC76B8D9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnchainedLauncher.CLI.Tests", "UnchainedLauncher.CLI.Tests\UnchainedLauncher.CLI.Tests.csproj", "{B3A5C9DE-6A90-49F1-95EC-908973A1A21F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +42,42 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|x64.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|x64.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|x86.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|x86.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|Any CPU.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|Any CPU.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|x64.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|x64.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|x86.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|x86.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|Any CPU.Build.0 = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|x64.ActiveCfg = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|x64.Build.0 = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|x86.ActiveCfg = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|x86.Build.0 = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|x64.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|x86.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|Any CPU.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|Any CPU.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|x64.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|x64.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|x86.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|x86.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|Any CPU.Build.0 = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|x64.ActiveCfg = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|x64.Build.0 = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|x86.ActiveCfg = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|x86.Build.0 = Release|Any CPU {0C235765-7B68-48BB-A3E5-87E12C512540}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0C235765-7B68-48BB-A3E5-87E12C512540}.Debug|Any CPU.Build.0 = Debug|Any CPU {0C235765-7B68-48BB-A3E5-87E12C512540}.Debug|x64.ActiveCfg = Debug|Any CPU