diff --git a/.gitignore b/.gitignore index 27d1bdb3..1b8b1ea2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ packages .idea out -nul \ No newline at end of file +nul +/annals/ diff --git a/README.md b/README.md index 35eec301..4e3395ba 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ Check out [the wiki](https://unchained.wiki) for more information about modding ## Features - **Launch Chivalry 2**: Easily launch Chivalry 2 with custom configurations. -- **Switch Between Vanilla, Client Side Modded, Unchained**: Seamlessly switch between vanilla and modded installs without manually moving any files around. Use unchained mode to connect to community hosted servers. +- **Switch Between Vanilla and Unchained**: Switch without moving files manually. Use Unchained for mods, plugins, and community servers. - **Mod Management**: Download and manage mods for Chivalry 2. +- **DLL Management**: Enable registry-approved native plugins with SHA-256 and size validation. - **Server Hosting**: Configure and host a server for your friends to join in Unchained Mode ## Community diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DependencyTypesTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DependencyTypesTests.cs new file mode 100644 index 00000000..4b598406 --- /dev/null +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DependencyTypesTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Dlls; + +namespace UnchainedLauncher.Core.Tests.Unit.Services.Dlls { + public sealed class DependencyTypesTests { + private static Release Pak(string name, params Dependency[] plugins) => new("v1.0.0", "hash", name + ".pak", + DateTime.UtcNow, new ModInfo("https://github.com/Test/" + name, name, "Description", null, null, [], [], + PluginDependencies: plugins.ToList()), null!, null); + + [Fact] + public void ModPluginWarningListsOneModAndDeduplicatesItsPluginRequirements() { + var dependency = new Dependency("https://github.com/Test/Native", ">=1.0.0,<2.0.0"); + var error = Assert.Throws(() => + DependencyValidator.Validate([Pak("Arena", dependency, dependency), Pak("NoPlugins")], [])); + Assert.Equal("One mod requires a DLL plugin. Enable them in the Plugin Manager.\n\nArena:\n\t- Native >=1.0.0,<2.0.0", + error.Message); + } + + [Fact] + public void ModPluginWarningGroupsAllModsIncludingPakDependencies() { + var alpha = Pak("Alpha", new Dependency("https://github.com/Test/Native", "^1.0.0")); + var beta = Pak("Beta", new Dependency("https://github.com/Test/ZPlugin", "2.0.0"), + new Dependency("https://github.com/Test/APlugin", "~1.2.0")); + alpha = alpha with { Info = alpha.Info with { ModDependencies = [new Dependency(beta.Info.RepoUrl, "1.0.0")] } }; + var error = Assert.Throws(() => DependencyValidator.Validate([beta, alpha, Pak("NoPlugins")], [])); + Assert.Equal("Some mods require DLL plugins. Enable them in the Plugin Manager.\n\nAlpha:\n\t- Native ^1.0.0\n\nBeta:\n\t- APlugin ~1.2.0\n\t- ZPlugin 2.0.0", + error.Message); + } + + [Fact] + public void ModPluginWarningMergesCaseInsensitiveRepositoriesAndKeepsFirstDisplayName() { + var dependency = new Dependency("https://github.com/Test/Native", "1.0.0"); + var first = Pak("Arena", dependency); + var second = first with { Info = first.Info with { RepoUrl = first.Info.RepoUrl.ToLowerInvariant(), Name = "Other name" } }; + var error = Assert.Throws(() => DependencyValidator.Validate([first, second], [])); + Assert.Equal("One mod requires a DLL plugin. Enable them in the Plugin Manager.\n\nArena:\n\t- Native 1.0.0", error.Message); + } + + [Fact] + public void ModPluginWarningUsesPluralPluginsForOneMod() { + var error = Assert.Throws(() => DependencyValidator.Validate([ + Pak("Arena", new Dependency("https://github.com/Test/Native", "1.0.0"), + new Dependency("https://github.com/Test/Other", "2.0.0"))], [])); + Assert.StartsWith("One mod requires DLL plugins.", error.Message); + } + + [Fact] + public void ModPluginWarningKeepsSharedPluginUnderEachMod() { + var dependency = new Dependency("https://github.com/Test/Native", "1.0.0"); + var error = Assert.Throws(() => DependencyValidator.Validate([Pak("Beta", dependency), Pak("Alpha", dependency)], [])); + Assert.Equal("Some mods require a DLL plugin. Enable them in the Plugin Manager.\n\nAlpha:\n\t- Native 1.0.0\n\nBeta:\n\t- Native 1.0.0", + error.Message); + } + + [Fact] + public void ManuallyEnabledCompatiblePluginsPassAndDoNotAppearInWarning() { + var native = new DllRelease("Test", "Native", "1.5.0", "https://example.com/Native.dll", new string('a', 64), 1); + var satisfied = Pak("Satisfied", new Dependency("https://github.com/Test/Native", "^1.0.0")); + DependencyValidator.Validate([satisfied], [native]); + var missing = new Dependency("https://github.com/Test/Other", "2.0.0"); + var partial = Pak("Partial", satisfied.Info.EffectivePluginDependencies[0], missing); + var error = Assert.Throws(() => DependencyValidator.Validate([satisfied, partial], [native])); + Assert.Equal("One mod requires a DLL plugin. Enable them in the Plugin Manager.\n\nPartial:\n\t- Other 2.0.0", error.Message); + Assert.Throws(() => DependencyValidator.Validate([satisfied], [native with { Version = "2.0.0" }])); + DependencyValidator.Validate([Pak("NoPlugins")], []); + } + + [Fact] + public void LegacyModDependenciesAreReadAsModDependencies() { + var json = """ + {"repo_url":"https://github.com/Test/Pak","name":"Pak","description":"Description", + "authors":["Author"],"image_urls":[], + "dependencies":[{"repo_url":"https://github.com/Test/OldPak","version":"1.0.0"}], + "mod_dependencies":[{"repo_url":"https://github.com/Test/NewPak","version":"2.0.0"}], + "plugin_dependencies":[{"repo_url":"https://github.com/Test/Native","version":"^1.0.0"}]} + """; + var info = JsonSerializer.Deserialize(json)!; + Assert.Equal(2, info.EffectiveModDependencies.Count); + Assert.Equal("https://github.com/Test/Native", Assert.Single(info.EffectivePluginDependencies).RepoUrl); + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(info))!; + Assert.Equal(info.EffectiveModDependencies, restored.EffectiveModDependencies); + } + + [Fact] + public void ModAndPluginCrossDependenciesRequireCorrectEnabledTypesAndVersions() { + var nativeDependency = new Dependency("https://github.com/Test/Native", ">=1.0.0,<2.0.0"); + var pakInfo = new ModInfo("https://github.com/Test/Pak", "Pak", "Description", null, null, [], ["Author"], + ModDependencies: [], PluginDependencies: [nativeDependency]); + var pak = new Release("v1.0.0", "hash", "Pak.pak", DateTime.UtcNow, pakInfo, null!, null); + var nativeInfo = new PluginInfo("Native", "Description", ["Author"], [], + ModDependencies: [new Dependency("https://github.com/Test/Pak", "1.0.0")]); + var native = new DllRelease("Test", "Native", "1.0.0", "https://example.com/Native.dll", new string('a', 64), 1, nativeInfo); + DependencyValidator.Validate([pak], [native]); + Assert.Throws(() => DependencyValidator.Validate([pak], [])); + Assert.Throws(() => DependencyValidator.Validate([], [native])); + Assert.Throws(() => DependencyValidator.Validate([pak], [native with { Version = "2.0.0" }])); + Assert.Throws(() => DependencyValidator.Validate([pak with { Tag = "v2.0.0" }], [native])); + + var wrongType = native with { Info = nativeInfo with { ModDependencies = [nativeDependency] } }; + Assert.Throws(() => DependencyValidator.Validate([pak], [wrongType])); + } + + [Fact] + public void MissingModQueryReportsFirstRequirementInPluginAndDependencyOrder() { + var first = new DllRelease("Test", "First", "1.0.0", "https://example.com/First.dll", new string('a', 64), 1, + new PluginInfo("First", "Description", ["Author"], ModDependencies: [ + new Dependency("https://github.com/Test/Existing", "1.0.0"), + new Dependency("https://github.com/Test/Missing", "2.0.0"), + new Dependency("https://github.com/Test/Other", "3.0.0")])); + var second = first with { Name = "Second" }; + var error = Assert.Throws(() => DependencyValidator.Validate([Pak("Existing")], [first, second])); + Assert.Equal("Enable a compatible mod for Test/First: https://github.com/Test/Missing 2.0.0", error.Message); + DependencyValidator.Validate([], [first with { Info = null }]); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs new file mode 100644 index 00000000..14ee208c --- /dev/null +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs @@ -0,0 +1,1098 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using UnchainedLauncher.Core.Services.Dlls; + +namespace UnchainedLauncher.Core.Tests.Unit.Services.Dlls { + public sealed class DllManagerTests : IDisposable { + private readonly string _dir = Path.Combine(Path.GetTempPath(), "dll-tests-" + Guid.NewGuid().ToString("N")); + private readonly byte[] _binary = Encoding.UTF8.GetBytes("registered DLL bytes"); + private readonly FixtureHandler _handler = new(); + private readonly HttpClient _http; + private string StatePath => Path.Combine(_dir, "dll-manager.json"); + private string PluginPath => Path.Combine(_dir, "Plugins"); + + public DllManagerTests() { Directory.CreateDirectory(_dir); _http = new HttpClient(_handler); } + + private DllRelease Release(string name = "UnchainedPlugin", string version = "1.0.0") => new( + "Chiv2-Community", name, version, "https://example.com/" + name + ".dll", + Convert.ToHexString(SHA256.HashData(_binary)).ToLowerInvariant(), _binary.Length); + + private DllManager Manager() => new(PluginPath, StatePath, _http); + + [Fact] + public async Task RemovedReleaseCanBeDisabledButNeverReenabled() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + Index(); + await manager.RefreshAsync(); + Assert.Throws(() => manager.SetEnabled(Release(), true)); + manager.SetEnabled(Release(), false); + Assert.Empty(manager.EnabledSelections); + Assert.Empty(Manager().EnabledSelections); + Assert.Empty(manager.OpenForInjection()); + } + + [Fact] + public async Task DisableDoesNotRequireCurrentSourcesAndPersistsWithoutReapproving() { + Index(Release()); + IReadOnlyList sources = [DllManager.DefaultDatabaseUrl]; + var manager = new DllManager(PluginPath, StatePath, _http, registryUrls: () => sources); + await manager.RefreshAsync(); + sources = []; + Assert.Throws(() => manager.SetEnabled(Release(), true)); + manager.Disable(DllManager.DefaultPluginId.ToLowerInvariant()); + Assert.Empty(manager.EnabledSelections); + Assert.Equal("{}", File.ReadAllText(StatePath)); + Assert.Throws(() => manager.OpenForInjection()); + var restarted = new DllManager(PluginPath, StatePath, _http, registryUrls: () => sources); + Assert.Empty(restarted.EnabledSelections); + await restarted.RefreshAsync(); + Assert.Empty(restarted.OpenForInjection()); + } + + [Fact] + public async Task AlternateRegistriesMergeAndPersistApprovals() { + const string alternate = "https://example.com/registry/dll_db"; + Index(Release()); + var other = Release("OtherPlugin"); + var info = new PluginInfo("Other", "Description", ["Author"], []); + var record = new PluginRelease("v1.0.0", other.Sha256, "OtherPlugin.dll", other.DownloadUrl, + other.Size, DateTimeOffset.Parse("2023-01-01T00:00:00Z"), info, "# Plugin notes"); + _handler.Responses[alternate + "/index.json"] = null; + _handler.Responses[alternate + "/plugin_list_index.txt"] = "Chiv2-Community/OtherPlugin\n"; + _handler.Responses[alternate + "/packages/Chiv2-Community/OtherPlugin.json"] = + JsonSerializer.Serialize(new PluginPackage(info, [record])); + var manager = new DllManager(PluginPath, StatePath, _http, registryUrls: () => [DllManager.DefaultDatabaseUrl, alternate]); + await manager.RefreshAsync(); + Assert.Equal(2, manager.Releases.Count); + var registered = manager.Releases.Single(release => release.Name == "OtherPlugin"); + Assert.Equal("# Plugin notes", registered.ReleaseNotesMarkdown); + manager.SetEnabled(registered, true); + await manager.PrepareAsync(); + var restarted = new DllManager(PluginPath, StatePath, _http, registryUrls: () => [alternate, DllManager.DefaultDatabaseUrl]); + Assert.Equal(2, restarted.Releases.Count); + Assert.True(restarted.IsEnabled(registered)); + } + + [Fact] + public async Task RegistryChangesInvalidateCachedAndLiveApprovals() { + Index(Release()); + IReadOnlyList sources = [DllManager.DefaultDatabaseUrl]; + var manager = new DllManager(PluginPath, StatePath, _http, registryUrls: () => sources); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + sources = []; + Assert.Throws(() => manager.OpenForInjection()); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + var restarted = new DllManager(PluginPath, StatePath, _http, registryUrls: () => sources); + Assert.Empty(restarted.Releases); + await manager.RefreshAsync(); + Assert.Empty(manager.Releases); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + } + + [Fact] + public async Task ConflictingAlternateRegistryPreservesPreviousApprovals() { + const string alternate = "https://example.com/registry/dll_db"; + Index(Release()); + IReadOnlyList sources = [DllManager.DefaultDatabaseUrl]; + var manager = new DllManager(PluginPath, StatePath, _http, registryUrls: () => sources); + await manager.RefreshAsync(); + var cached = File.ReadAllText(StatePath + ".index.json"); + _handler.Responses[alternate + "/index.json"] = JsonSerializer.Serialize(new[] { Release() with { Sha256 = new string('a', 64) } }); + _handler.Responses[alternate + "/plugin_list_index.txt"] = null; + sources = [DllManager.DefaultDatabaseUrl, alternate]; + await Assert.ThrowsAsync(() => manager.RefreshAsync()); + Assert.Equal(cached, File.ReadAllText(StatePath + ".index.json")); + Assert.Throws(() => manager.OpenForInjection()); + } + + [Fact] + public async Task IdenticalRecordsAndModOnlyRegistriesAreSupported() { + const string alternate = "https://example.com/registry/dll_db"; + const string modOnly = "https://example.com/mod-only/dll_db"; + Index(Release()); + _handler.Responses[alternate + "/index.json"] = _handler.Index; + _handler.Responses[alternate + "/plugin_list_index.txt"] = null; + _handler.Responses[modOnly + "/index.json"] = null; + _handler.Responses[modOnly + "/plugin_list_index.txt"] = null; + var manager = new DllManager(PluginPath, StatePath, _http, registryUrls: () => [DllManager.DefaultDatabaseUrl, alternate, modOnly]); + await manager.RefreshAsync(); + Assert.Single(manager.Releases); + } + + [Fact] + public void PluginImagesRoundTripAndAreOptional() { + var info = new PluginInfo("Plugin", "Description", ["Author"], [], + "https://example.com/icon.png", ["https://example.com/screenshot.png"]); + var release = Release() with { Info = info }; + var parsed = Assert.Single(DllManager.ParseIndex(JsonSerializer.Serialize(new[] { release }))); + Assert.Equal(info.IconUrl, parsed.Info!.IconUrl); + Assert.Equal(info.ImageUrls, parsed.Info.ImageUrls); + var optional = new PluginInfo("Plugin", "Description", ["Author"], []); + optional.Validate(); + Assert.Null(optional.IconUrl); + Assert.Null(optional.ImageUrls); + } + + [Theory] + [InlineData("http://example.com/image.png")] + [InlineData("file:///image.png")] + [InlineData("https://user:password@example.com/image.png")] + public void InvalidPluginImagesAreRejected(string url) { + Assert.Throws(() => new PluginInfo("Plugin", "Description", ["Author"], [], url).Validate()); + Assert.Throws(() => new PluginInfo("Plugin", "Description", ["Author"], [], null, [url]).Validate()); + } + + [Fact] + public async Task PluginDependenciesMustBeExplicitlyEnabledAndLoadFirst() { + var info = new PluginInfo("Display Name", "Description", ["Author"], + [new UnchainedLauncher.Core.JsonModels.ModMetadata.Dependency("https://github.com/Chiv2-Community/OtherPlugin", ">=1.0.0,<2.0.0")]); + var plugin = Release() with { Info = info }; + var other = Release("OtherPlugin"); + var incompatible = Release("OtherPlugin", "2.0.0"); + Index(plugin, other, incompatible); + var manager = Manager(); + await manager.RefreshAsync(); + Assert.Equal("Display Name", manager.Releases[0].Info!.Name); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Throws(() => manager.OpenForInjection()); + manager.SetEnabled(incompatible, true); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + manager.SetEnabled(other, true); + await manager.PrepareAsync(); + var files = manager.OpenForInjection(); + try { + Assert.Equal(new[] { "OtherPlugin.dll", "UnchainedPlugin.dll" }, files.Select(file => Path.GetFileName(file.Path))); + } + finally { foreach (var file in files) file.Dispose(); } + } + + [Fact] + public async Task PluginModDependenciesAreCheckedAgainBeforeInjection() { + var plugin = Release() with { + Info = new PluginInfo("Plugin", "Description", ["Author"], [], + ModDependencies: [new UnchainedLauncher.Core.JsonModels.ModMetadata.Dependency("https://github.com/Test/Pak", "1.0.0")]) + }; + Index(plugin); + var enabledMods = new List(); + var manager = new DllManager(PluginPath, StatePath, _http, () => enabledMods); + await manager.RefreshAsync(); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + var info = new UnchainedLauncher.Core.JsonModels.ModMetadata.ModInfo("https://github.com/Test/Pak", "Pak", "Description", null, null, [], ["Author"], + ModDependencies: [], PluginDependencies: []); + enabledMods.Add(new UnchainedLauncher.Core.JsonModels.ModMetadata.Release("v1.0.0", "hash", "Pak.pak", DateTime.UtcNow, info, null!, null)); + await manager.PrepareAsync(); + var selected = manager.OpenForInjection(); + try { Assert.Single(selected); } + finally { foreach (var file in selected) file.Dispose(); } + enabledMods.Clear(); + Assert.Throws(() => manager.OpenForInjection()); + } + + [Fact] + public async Task CircularDependenciesFailBeforeDownloadsOrInjection() { + var first = Release() with { + Info = new PluginInfo("First", "Description", ["Author"], + [new UnchainedLauncher.Core.JsonModels.ModMetadata.Dependency("https://github.com/Chiv2-Community/OtherPlugin", "1.0.0")]) + }; + var second = Release("OtherPlugin") with { + Info = new PluginInfo("Second", "Description", ["Author"], + [new UnchainedLauncher.Core.JsonModels.ModMetadata.Dependency("https://github.com/Chiv2-Community/UnchainedPlugin", "1.0.0")]) + }; + Index(first, second); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(manager.Releases.Single(release => release.Id == second.Id), true); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Throws(() => manager.OpenForInjection()); + } + + [Theory] + [InlineData("1.2.0", "^1.0.0", true)] + [InlineData("2.0.0", "^1.0.0", false)] + [InlineData("0.2.5", "^0.2.0", true)] + [InlineData("0.3.0", "^0.2.0", false)] + [InlineData("1.0.0-RC2", ">=1.0.0", false)] + public void PluginDependencyRangesUseSemver(string version, string range, bool expected) { + Assert.Equal(expected, PluginInfo.MatchesVersion(version, range)); + } + + private string Package(string id, params DllRelease[] releases) => JsonSerializer.Serialize(new PluginPackage( + releases.OrderByDescending(release => release.ReleaseDate).First(release => release.Info != null).Info!, + releases.Select(release => new PluginRelease(release.Tag ?? "v" + release.Version, release.Sha256, + release.DllFileName, release.DownloadUrl, release.Size, release.ReleaseDate, release.Info, release.ReleaseNotesMarkdown)).ToArray())); + + private void Packages(params DllRelease[] releases) { + _handler.Responses[DllManager.DefaultPackageListUrl] = string.Join("\n", releases.Select(release => release.Id).Distinct()); + foreach (var group in releases.GroupBy(release => release.Id)) + _handler.Responses[$"{DllManager.DefaultDatabaseUrl}/packages/{group.Key}.json"] = Package(group.Key, group.ToArray()); + _handler.Binary = _binary; + } + + private DllRelease ModernRelease(string name = "UnchainedPlugin", string target = "shared", string version = "1.0.0") => + Release(name, version) with { + Info = new PluginInfo("Display " + name, "Description", ["Author"], RepoUrl: "https://github.com/Chiv2-Community/" + name, Target: target), + Tag = "v" + version, + DllFileName = name + "-native.dll", + ReleaseDate = DateTimeOffset.Parse("2026-03-23T15:37:46+00:00"), + ReleaseNotesMarkdown = "Release notes" + }; + + [Fact] + public async Task ModernPackagesAreAuthoritativeAndPreservePinsAcrossCacheMigration() { + var legacy = Release(); + Index(legacy); + var manager = Manager(); + await manager.RefreshAsync(); + var modern = ModernRelease(); + Packages(modern); + _handler.Responses[DllManager.DefaultIndexUrl] = "invalid stale index must not be read"; + await manager.RefreshAsync(); + Assert.True(manager.IsEnabled(modern)); + Assert.Equal(modern.Tag, Assert.Single(manager.Releases).Tag); + Assert.Equal("shared", Assert.Single(manager.Releases).Info!.Target); + await manager.PrepareAsync(); + var files = manager.OpenForInjection(); + try { Assert.Equal(modern.DllFileName, Path.GetFileName(Assert.Single(files).Path)); } + finally { foreach (var file in files) file.Dispose(); } + _handler.Fail = true; + var cached = Manager(); + Assert.True(cached.IsEnabled(Assert.Single(cached.Releases))); + await cached.PrepareAsync(); + } + + [Fact] + public async Task RemovedPackageCannotBeReapprovedByStaleLegacyIndex() { + var release = ModernRelease(); + Packages(release); + var manager = Manager(); + await manager.RefreshAsync(); + _handler.Index = JsonSerializer.Serialize(new[] { release }); + _handler.Responses[DllManager.DefaultPackageListUrl] = ""; + await manager.RefreshAsync(); + Assert.Empty(manager.Releases); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + } + + [Fact] + public async Task LaunchTargetsFilterDownloadsAndInjectionWithoutChangingSelections() { + var shared = ModernRelease(); + var client = ModernRelease("ClientPlugin", "client"); + var server = ModernRelease("ServerPlugin", "server"); + Packages(shared, client, server); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(manager.Releases.Single(release => release.Id == client.Id), true); + manager.SetEnabled(manager.Releases.Single(release => release.Id == server.Id), true); + await manager.PrepareAsync(); + var files = manager.OpenForInjection(); + try { Assert.Equal(new[] { client.DllFileName, shared.DllFileName }.Order(), files.Select(file => Path.GetFileName(file.Path)).Order()); } + finally { foreach (var file in files) file.Dispose(); } + Assert.DoesNotContain(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories), file => Path.GetFileName(file) == server.DllFileName); + if (!OperatingSystem.IsWindows()) { + await Assert.ThrowsAsync(() => manager.PrepareAsync(server: true)); + Assert.Contains(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories), file => Path.GetFileName(file) == client.DllFileName); + return; + } + await manager.PrepareAsync(server: true); + files = manager.OpenForInjection(server: true); + try { Assert.Equal(new[] { server.DllFileName, shared.DllFileName }.Order(), files.Select(file => Path.GetFileName(file.Path)).Order()); } + finally { foreach (var file in files) file.Dispose(); } + // Restore the requested side from cache without changing selections. + await manager.PrepareAsync(server: false, allowDownloads: false); + files = manager.OpenForInjection(server: false); + try { Assert.Equal(new[] { client.DllFileName, shared.DllFileName }.Order(), files.Select(file => Path.GetFileName(file.Path)).Order()); } + finally { foreach (var file in files) file.Dispose(); } + Assert.True(manager.IsEnabled(manager.Releases.Single(release => release.Id == client.Id))); + Assert.True(manager.IsEnabled(manager.Releases.Single(release => release.Id == server.Id))); + } + + [Fact] + public async Task IncompatibleDependencyTargetFailsBeforeDownload() { + var client = ModernRelease("OtherPlugin", "client"); + var shared = ModernRelease() with { + Info = ModernRelease().Info! with { + PluginDependencies = [new UnchainedLauncher.Core.JsonModels.ModMetadata.Dependency("https://github.com/Chiv2-Community/OtherPlugin", "1.0.0")] + } + }; + Packages(shared, client); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(manager.Releases.Single(release => release.Id == client.Id), true); + await Assert.ThrowsAsync(() => manager.PrepareAsync(server: true)); + Assert.False(Directory.Exists(PluginPath)); + Assert.Throws(() => manager.OpenForInjection(server: true)); + await manager.PrepareAsync(server: false); + } + + [Fact] + public async Task OppositeSidePakRequirementsDoNotBlockClientLaunch() { + var shared = ModernRelease(); + var server = ModernRelease("ServerPlugin", "server"); + server = server with { + Info = server.Info! with { + ModDependencies = [new UnchainedLauncher.Core.JsonModels.ModMetadata.Dependency("https://github.com/Org/Pak.Mod", "1.0.0")] + } + }; + Packages(shared, server); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(manager.Releases.Single(release => release.Id == server.Id), true); + await manager.PrepareAsync(server: false); + var files = manager.OpenForInjection(server: false); + try { Assert.Single(files); } + finally { foreach (var file in files) file.Dispose(); } + await Assert.ThrowsAsync(() => manager.PrepareAsync(server: true)); + Assert.Throws(() => manager.OpenForInjection(server: true)); + } + + [Fact] + public void PackagesPermitApprovedHistoricalNullMetadataAndDefaultSharedTarget() { + var latest = ModernRelease(); + var historical = Release(version: "0.5.0"); + var json = Package(latest.Id, latest, historical); + var releases = DllManager.ParsePackage(json, latest.Id); + Assert.Equal(2, releases.Count); + Assert.Null(releases.Single(release => release.Version == "0.5.0").Info); + Assert.Null(releases.Single(release => release.Version == "0.5.0").DllFileName); + var minimalInfo = JsonSerializer.Deserialize("""{"name":"Plugin","description":"Test","authors":["Author"]}""")!; + minimalInfo.Validate(); + Assert.Equal("shared", minimalInfo.Target); + } + + [Fact] + public void PackageInfoComparisonUsesSchemaDefaultsAndIgnoresSchemaAnnotation() { + var release = ModernRelease() with { + Info = ModernRelease().Info! with { + Schema = "https://example.test/plugin.schema.json", + PluginDependencies = [], + ModDependencies = [], + ImageUrls = [] + } + }; + var json = JsonSerializer.Serialize(new PluginPackage(ModernRelease().Info!, [new PluginRelease(release.Tag!, release.Sha256, + release.DllFileName, release.DownloadUrl, release.Size, release.ReleaseDate, release.Info, null)])); + Assert.Single(DllManager.ParsePackage(json, release.Id)); + } + + [Theory] + [InlineData("both")] + [InlineData("Client")] + [InlineData("")] + [InlineData(null)] + public void InvalidPluginTargetsAreRejected(string? target) { + var release = ModernRelease() with { Info = ModernRelease().Info! with { Target = target! } }; + Assert.Throws(() => DllManager.ParsePackage(Package(release.Id, release), release.Id)); + } + + [Fact] + public void PackageValidationRejectsIdentityMismatchMissingFieldsAndDuplicates() { + var release = ModernRelease(); + var json = Package(release.Id, release); + Assert.Throws(() => DllManager.ParsePackage(json, "Another/Plugin")); + Assert.Throws(() => DllManager.ParsePackage(Package(release.Id, release, release), release.Id)); + var node = System.Text.Json.Nodes.JsonNode.Parse(json)!; + node["releases"]![0]!.AsObject().Remove("info"); + Assert.Throws(() => DllManager.ParsePackage(node.ToJsonString(), release.Id)); + Assert.Throws(() => DllManager.ParsePackage("""{"latest_release_info":null,"releases":[]}""", release.Id)); + } + + [Fact] + public void PakDependenciesAllowDottedRepositoriesButPluginsDoNot() { + var dependency = new UnchainedLauncher.Core.JsonModels.ModMetadata.Dependency("https://github.com/Org/Pak.Mod", "^1.0.0"); + new PluginInfo("Plugin", "Description", ["Author"], ModDependencies: [dependency]).Validate(); + Assert.Throws(() => new PluginInfo("Plugin", "Description", ["Author"], PluginDependencies: [dependency]).Validate()); + Assert.Throws(() => new PluginInfo("Plugin", "Description", ["Author"], + ModDependencies: [dependency with { RepoUrl = "https://github.com/Org/.." }]).Validate()); + } + + [Theory] + [InlineData("null")] + [InlineData("invalid")] + [InlineData("duplicate")] + public void IndexAndPackageShareReleaseValidation(string failure) { + var release = ModernRelease(); + var node = System.Text.Json.Nodes.JsonNode.Parse(Package(release.Id, release))!; + var releases = node["releases"]!.AsArray(); + switch (failure) { + case "null": releases.Add((System.Text.Json.Nodes.JsonNode?)null); break; + case "invalid": releases[0]!["hash"] = "invalid"; break; + case "duplicate": releases.Add(releases[0]!.DeepClone()); break; + } + Assert.Throws(() => DllManager.ParsePackage(node.ToJsonString(), release.Id)); + DllRelease?[] entries = failure switch { + "null" => [release, null], + "invalid" => [release with { Sha256 = "invalid" }], + _ => [release, release with { Organization = release.Organization.ToLowerInvariant() }] + }; + Assert.Throws(() => DllManager.ParseIndex(JsonSerializer.Serialize(entries))); + } + + [Fact] + public async Task DisabledDownloadsRequireExistingValidatedFilesAndWorkOffline() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + await Assert.ThrowsAsync(() => manager.PrepareAsync(allowDownloads: false)); + Assert.False(Directory.Exists(PluginPath)); + await manager.PrepareAsync(); + _handler.Fail = true; + await manager.PrepareAsync(allowDownloads: false); + } + + [Fact] + public async Task UpdateCandidatesRespectPrereleasePermissionLaunchTargetAndEnabledSelection() { + var current = ModernRelease(); + Packages(current); + var manager = Manager(); + await manager.RefreshAsync(); + Packages(current, ModernRelease(version: "2.0.0"), ModernRelease(target: "server", version: "3.0.0"), + ModernRelease(target: "client", version: "4.0.0-RC1"), ModernRelease("DisabledPlugin", version: "10.0.0")); + await manager.RefreshAsync(); + Assert.Equal("2.0.0", Assert.Single(manager.GetUpdateCandidates(server: false, allowUnstable: false)).Latest.Version); + Assert.Equal("4.0.0-RC1", Assert.Single(manager.GetUpdateCandidates(server: false, allowUnstable: true)).Latest.Version); + Assert.Equal("3.0.0", Assert.Single(manager.GetUpdateCandidates(server: true, allowUnstable: true)).Latest.Version); + Assert.True(manager.IsEnabled(current)); // Merely checking does not change the pin. + } + + private sealed class UpdateDialogue(bool approve) : UnchainedLauncher.Core.Services.IUserDialogueSpawner { + public List Updates { get; } = []; + public List Messages { get; } = []; + public void DisplayMessage(string message) => Messages.Add(message); + public UnchainedLauncher.Core.Services.UserDialogueChoice DisplayYesNoMessage(string message, string caption) => + approve ? UnchainedLauncher.Core.Services.UserDialogueChoice.Yes : UnchainedLauncher.Core.Services.UserDialogueChoice.No; + public UnchainedLauncher.Core.Services.UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, + string yesButtonText, string noButtonText, string? cancelButtonText, IEnumerable updates) { + Updates.AddRange(updates); + return DisplayYesNoMessage(messageText, titleText); + } + public Action DisplayProgress(UnchainedLauncher.Core.Utilities.MemoryProgress progress) => () => { }; + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task LaunchSettingsControlConfirmedAutomaticUpdates(bool allowUnstable, bool approve) { + var current = Release(); + Index(current); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + Index(current, Release(version: "2.0.0+build.abc"), Release(version: "3.0.0-RC1+build.abc")); + var dialogue = new UpdateDialogue(approve); + var preparer = new UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers.DllLaunchPreparer(manager, dialogue); + var options = new UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchOptions([], LanguageExt.Option.None, "", + true, allowUnstable, LanguageExt.Option.None, + LanguageExt.Option.None); + Assert.True((await preparer.PrepareLaunch(options)).IsSome); + var expectedVersion = allowUnstable ? "3.0.0-RC1+build.abc" : "2.0.0+build.abc"; + Assert.Equal(expectedVersion, Assert.Single(dialogue.Updates).LatestVersion); + Assert.Equal($"https://github.com/{current.Id}/releases/tag/{Uri.EscapeDataString(expectedVersion)}", + Assert.Single(dialogue.Updates).ReleaseUrl); + Assert.Equal(approve ? expectedVersion : current.Version, Assert.Single(manager.Releases.Where(manager.IsEnabled)).Version); + Assert.Empty(dialogue.Messages); + } + + [Fact] + public async Task DisabledAutomaticUpdatesSkipRefreshOffersAndDownloads() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + _handler.Fail = true; + var dialogue = new UpdateDialogue(true); + var preparer = new UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers.DllLaunchPreparer(manager, dialogue); + var options = new UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchOptions([], LanguageExt.Option.None, "", + false, false, LanguageExt.Option.None, + LanguageExt.Option.None); + Assert.True((await preparer.PrepareLaunch(options)).IsNone); + Assert.Empty(dialogue.Updates); + Assert.Contains("downloads are disabled", Assert.Single(dialogue.Messages)); + _handler.Fail = false; + await manager.PrepareAsync(); + _handler.Fail = true; + Assert.True((await preparer.PrepareLaunch(options)).IsSome); + Assert.Empty(dialogue.Updates); + } + + private void Index(params DllRelease[] releases) { + _handler.Index = JsonSerializer.Serialize(releases); + _handler.Binary = _binary; + } + + [Theory] + [InlineData(null, "1.0.0%2Bbuild.abc")] + [InlineData("v1.0.0+build.abc", "v1.0.0%2Bbuild.abc")] + public void ReleaseUrlEscapesOriginalTagOrFallbackWithoutChangingRegistryJson(string? tag, string encodedTag) { + var release = Release(version: "1.0.0+build.abc") with { Tag = tag }; + release.Validate(); + Assert.Equal($"https://github.com/{release.Id}/releases/tag/{encodedTag}", release.GithubReleaseUrl); + var json = JsonSerializer.Serialize(release); + Assert.DoesNotContain("GithubReleaseUrl", json); + Assert.Equal(release, JsonSerializer.Deserialize(json)); + } + + [Theory] + [InlineData(null, "UnchainedPlugin.dll")] + [InlineData("CustomPlugin.dll", "CustomPlugin.dll")] + public void EffectiveFileNamePreservesAssetOrLegacyFallbackWithoutChangingRegistryJson(string? fileName, string expected) { + var release = Release() with { DllFileName = fileName }; + release.Validate(); + Assert.Equal(expected, release.EffectiveFileName); + var json = JsonSerializer.Serialize(release); + Assert.DoesNotContain("EffectiveFileName", json); + Assert.Equal(release, JsonSerializer.Deserialize(json)); + } + + [Fact] + public async Task DefaultPluginOnlyAndLooseFilesIgnored() { + Index(Release(), Release("OtherPlugin")); + Directory.CreateDirectory(PluginPath); + File.WriteAllText(Path.Combine(PluginPath, "arbitrary.dll"), "unregistered"); + var manager = Manager(); + await manager.RefreshAsync(); + Assert.True(manager.IsEnabled(Release())); + Assert.False(manager.IsEnabled(Release("OtherPlugin"))); + await manager.PrepareAsync(); + var files = manager.OpenForInjection(); + try { + Assert.Single(files); + Assert.Equal("UnchainedPlugin.dll", Path.GetFileName(files[0].Path)); + Assert.DoesNotContain("arbitrary", files[0].Path); + } + finally { foreach (var file in files) file.Dispose(); } + } + + [Fact] + public async Task AdditionalPluginCanBeEnabledAndDefaultDisabledAcrossRestart() { + var plugin = Release(); + var other = Release("OtherPlugin"); + Index(plugin, other); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(other, true); + manager.SetEnabled(plugin, false); + manager = Manager(); + Assert.False(manager.IsEnabled(plugin)); + Assert.True(manager.IsEnabled(other)); + await manager.PrepareAsync(); + var files = manager.OpenForInjection(); + try { Assert.Equal("OtherPlugin.dll", Path.GetFileName(Assert.Single(files).Path)); } + finally { foreach (var file in files) file.Dispose(); } + } + + [Fact] + public async Task InjectionIgnoresDisabledInstalledDllsAndStraysInManagedDirectories() { + var plugin = Release(); + var other = Release("OtherPlugin"); + Index(plugin, other); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(other, true); + await manager.PrepareAsync(); + var installed = manager.OpenForInjection(); + string pluginPath; + string otherPath; + try { + pluginPath = installed.Single(file => Path.GetFileName(file.Path) == "UnchainedPlugin.dll").Path; + otherPath = installed.Single(file => Path.GetFileName(file.Path) == "OtherPlugin.dll").Path; + } + finally { foreach (var file in installed) file.Dispose(); } + // Even byte-identical loose DLLs beside an approved DLL are not selected. + File.WriteAllBytes(Path.Combine(Path.GetDirectoryName(pluginPath)!, "stray.dll"), _binary); + File.WriteAllBytes(Path.Combine(PluginPath, "UnchainedPlugin.dll"), _binary); + manager.SetEnabled(other, false); + Assert.True(File.Exists(otherPath)); + var selected = manager.OpenForInjection(); + try { Assert.Equal(pluginPath, Assert.Single(selected).Path); } + finally { foreach (var file in selected) file.Dispose(); } + + manager.SetEnabled(plugin, false); + Assert.Empty(manager.OpenForInjection()); + // An empty managed selection never invokes native injection, even + // when DLLs remain on disk. This also runs on non-Windows hosts. + Assert.True(new UnchainedLauncher.Core.Services.Processes.DllInjector(manager) + .Inject(System.Diagnostics.Process.GetCurrentProcess())); + } + + [Fact] + public async Task InjectorRejectsTamperingWithoutFallingBackToLooseDlls() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + var selected = manager.OpenForInjection(); + string path; + try { path = Assert.Single(selected).Path; } + finally { foreach (var file in selected) file.Dispose(); } + File.WriteAllBytes(Path.Combine(PluginPath, "UnchainedPlugin.dll"), _binary); + File.WriteAllText(path, "tampered"); + Assert.False(new UnchainedLauncher.Core.Services.Processes.DllInjector(manager) + .Inject(System.Diagnostics.Process.GetCurrentProcess())); + File.Delete(path); + Assert.False(new UnchainedLauncher.Core.Services.Processes.DllInjector(manager) + .Inject(System.Diagnostics.Process.GetCurrentProcess())); + } + + [Fact] + public async Task VersionsStayPinnedUntilExplicitlyChanged() { + var first = Release(); + Index(first); + var manager = Manager(); + await manager.RefreshAsync(); + var second = Release(version: "2.0.0"); + Index(first, second); + await manager.RefreshAsync(); + Assert.True(manager.IsEnabled(first)); + Assert.False(manager.IsEnabled(second)); + manager.SetEnabled(second, true); + Assert.False(manager.IsEnabled(first)); + Assert.True(manager.IsEnabled(second)); + } + + [Fact] + public async Task SwitchingVersionsRestoresExternalCacheWithoutDownloading() { + var first = Release(); + var nextBytes = Encoding.UTF8.GetBytes("second registered DLL"); + var second = Release(version: "2.0.0") with { + Sha256 = Convert.ToHexString(SHA256.HashData(nextBytes)).ToLowerInvariant(), + Size = nextBytes.Length + }; + Index(first, second); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(first, true); + await manager.PrepareAsync(); + _handler.Binary = nextBytes; + manager.SetEnabled(second, true); + if (!OperatingSystem.IsWindows()) { + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Equal(_binary, File.ReadAllBytes(Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)))); + return; + } + await manager.PrepareAsync(); + Assert.Equal(nextBytes, File.ReadAllBytes(Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)))); + Assert.False(Directory.Exists(Path.Combine(PluginPath, ".cache"))); + _handler.Fail = true; + manager = Manager(); + manager.SetEnabled(first, true); + await manager.PrepareAsync(allowDownloads: false); + var files = manager.OpenForInjection(); + try { Assert.Equal(_binary, File.ReadAllBytes(Assert.Single(files).Path)); } + finally { foreach (var file in files) file.Dispose(); } + Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + Assert.Equal(2, Directory.GetFiles(Path.Combine(_dir, ".cache", "dlls"), "*.dll", SearchOption.AllDirectories).Length); + } + + [Fact] + public async Task MigratesOnlyRegisteredLegacyManagedFilesAndPreservesUnknownFiles() { + var release = Release(); + Index(release); + var legacy = Path.Combine(PluginPath, "Managed", release.Organization, release.Name, release.Sha256, "UnchainedPlugin.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(legacy)!); + File.WriteAllBytes(legacy, _binary); + var unknownName = Path.Combine(Path.GetDirectoryName(legacy)!, "unknown.dll"); + File.WriteAllBytes(unknownName, _binary); + var unknownVersion = Path.Combine(PluginPath, "Managed", release.Organization, release.Name, new string('0', 64), "UnchainedPlugin.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(unknownVersion)!); + File.WriteAllText(unknownVersion, "unregistered version"); + var loose = Path.Combine(PluginPath, "UnchainedPlugin.dll"); + File.WriteAllText(loose, "unregistered root DLL"); + var manager = Manager(); + await manager.RefreshAsync(); + _handler.Fail = true; + if (!OperatingSystem.IsWindows()) { + await Assert.ThrowsAsync(() => manager.PrepareAsync(allowDownloads: false)); + Assert.Equal(_binary, File.ReadAllBytes(legacy)); + Assert.Equal(_binary, File.ReadAllBytes(unknownName)); + Assert.Equal("unregistered version", File.ReadAllText(unknownVersion)); + Assert.Equal("unregistered root DLL", File.ReadAllText(loose)); + return; + } + await manager.PrepareAsync(allowDownloads: false); + Assert.False(File.Exists(legacy)); + Assert.Equal(_binary, File.ReadAllBytes(unknownName)); + Assert.Equal("unregistered version", File.ReadAllText(unknownVersion)); + Assert.Equal("unregistered root DLL", File.ReadAllText(loose)); + Assert.Single(Directory.GetFiles(Path.Combine(_dir, ".cache", "dlls"), "*.dll", SearchOption.AllDirectories)); + var files = manager.OpenForInjection(); + try { Assert.Equal(_binary, File.ReadAllBytes(Assert.Single(files).Path)); } + finally { foreach (var file in files) file.Dispose(); } + } + + [Fact] + public async Task DisabledManagedFilesAreCachedButUnregisteredVersionsStayInPlace() { + var release = Release(); + Index(release); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + var installed = Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + manager.Disable(release.Id); + if (!OperatingSystem.IsWindows()) { + await Assert.ThrowsAsync(() => manager.PrepareAsync(allowDownloads: false)); + Assert.Equal(_binary, File.ReadAllBytes(installed)); + Assert.Empty(manager.OpenForInjection()); + File.WriteAllText(installed, "unregistered version"); + await manager.PrepareAsync(allowDownloads: false); + Assert.Equal("unregistered version", File.ReadAllText(installed)); + return; + } + await manager.PrepareAsync(allowDownloads: false); + Assert.False(File.Exists(installed)); + Assert.Empty(manager.OpenForInjection()); + File.WriteAllText(installed, "unregistered version"); + await manager.PrepareAsync(allowDownloads: false); + Assert.Equal("unregistered version", File.ReadAllText(installed)); + Assert.Single(Directory.GetFiles(Path.Combine(_dir, ".cache", "dlls"), "*.dll", SearchOption.AllDirectories)); + manager.SetEnabled(release, true); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Equal("unregistered version", File.ReadAllText(installed)); + } + + [Fact] + public async Task FailedDownloadDoesNotChangeInstalledManagedSet() { + var first = Release(); + var second = Release(version: "2.0.0") with { Sha256 = new string('b', 64) }; + Index(first, second); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(first, true); + await manager.PrepareAsync(); + var installed = Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + manager.SetEnabled(second, true); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Equal(_binary, File.ReadAllBytes(installed)); + Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + Assert.Empty(Directory.GetFiles(Path.Combine(_dir, ".cache"), "*.tmp", SearchOption.AllDirectories)); + } + + [Fact] + public async Task SelectionChangesDuringDownloadDoNotPublishOrArchiveDlls() { + var first = Release(); + var nextBytes = Encoding.UTF8.GetBytes("second registered DLL"); + var second = Release(version: "2.0.0") with { + Sha256 = Convert.ToHexString(SHA256.HashData(nextBytes)).ToLowerInvariant(), + Size = nextBytes.Length + }; + Index(first, second); + var manager = Manager(); + await manager.RefreshAsync(); + manager.SetEnabled(first, true); + await manager.PrepareAsync(); + var installed = Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + manager.SetEnabled(second, true); + _handler.Binary = nextBytes; + _handler.OnBinaryRequest = () => manager.Disable(second.Id); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Equal(_binary, File.ReadAllBytes(installed)); + Assert.Empty(manager.EnabledSelections); + } + + [Fact] + public async Task CorruptCacheCannotBeRestoredOrInjected() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + var installed = Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + File.Delete(installed); + var cache = Assert.Single(Directory.GetFiles(Path.Combine(_dir, ".cache"), "*.dll", SearchOption.AllDirectories)); + File.WriteAllText(cache, "tampered"); + await Assert.ThrowsAsync(() => manager.PrepareAsync(allowDownloads: false)); + Assert.False(File.Exists(installed)); + Assert.Throws(() => manager.OpenForInjection()); + } + + [Fact] + public async Task RevokedManagedReleaseIsLeftInPlaceAndNeverMovedToCache() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + var installed = Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + var cached = Assert.Single(Directory.GetFiles(Path.Combine(_dir, ".cache"), "*.dll", SearchOption.AllDirectories)); + File.Delete(cached); + Index(); + await manager.RefreshAsync(); + manager.Disable(Release().Id); + await manager.PrepareAsync(allowDownloads: false); + Assert.Equal(_binary, File.ReadAllBytes(installed)); + Assert.Empty(Directory.GetFiles(Path.Combine(_dir, ".cache"), "*.dll", SearchOption.AllDirectories)); + Assert.Empty(manager.OpenForInjection()); + } + + [Fact] + public async Task LooseRegisteredBytesAreNotAdoptedAsManagedFiles() { + Index(Release()); + Directory.CreateDirectory(PluginPath); + var loose = Path.Combine(PluginPath, "UnchainedPlugin.dll"); + File.WriteAllBytes(loose, _binary); + var manager = Manager(); + await manager.RefreshAsync(); + await Assert.ThrowsAsync(() => manager.PrepareAsync(allowDownloads: false)); + Assert.Equal(_binary, File.ReadAllBytes(loose)); + await manager.PrepareAsync(); + manager.Disable(Release().Id); + if (!OperatingSystem.IsWindows()) { + await Assert.ThrowsAsync(() => manager.PrepareAsync(allowDownloads: false)); + Assert.Equal(_binary, File.ReadAllBytes(loose)); + return; + } + await manager.PrepareAsync(allowDownloads: false); + Assert.Equal(_binary, File.ReadAllBytes(loose)); + Assert.Equal(loose, Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories))); + } + + [Fact] + public void CacheMustBeOutsidePluginDirectory() { + Assert.Throws(() => new DllManager(PluginPath, StatePath, _http, cacheDirectory: PluginPath)); + Assert.Throws(() => new DllManager(PluginPath, StatePath, _http, + cacheDirectory: Path.Combine(PluginPath, ".cache"))); + } + + [Fact] + public async Task ActivePublicationCollisionPreservesUnknownFileAndBlocksPreparation() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + var installed = Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + var cache = Assert.Single(Directory.GetFiles(Path.Combine(_dir, ".cache"), "*.dll", SearchOption.AllDirectories)); + File.Delete(installed); + var reached = false; + Action publishing = path => { + 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. + 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])!); + Assert.True(reached); + Assert.Equal("unknown user version", File.ReadAllText(installed)); + Assert.Equal(_binary, File.ReadAllBytes(cache)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(installed)!, "*.tmp")); + Assert.Throws(() => manager.OpenForInjection()); + } + + private static void ArchiveAtPublication(string source, string cache, DllRelease release, Action published) { + // Exercise exactly the helper used by PrepareAsync, not a simulated + // lock or pathname-delete algorithm. Reflection keeps this seam private. + var copy = typeof(DllManager).GetMethod("CopyValidated", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + try { copy.Invoke(null, [source, cache, release, true, published, null, true]); } + catch (System.Reflection.TargetInvocationException error) { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error.InnerException!).Throw(); + } + } + + [Theory] + [InlineData("write")] + [InlineData("replace")] + [InlineData("delete")] + public void ArchiveProtectsValidatedIdentityAfterCachePublication(string operation) { + if (!OperatingSystem.IsWindows()) return; + var source = Path.Combine(_dir, "approved.dll"); + var cache = Path.Combine(_dir, "cache", "approved.dll"); + var replacement = Path.Combine(_dir, "replacement.dll"); + File.WriteAllBytes(source, _binary); + File.WriteAllText(replacement, "unknown user version"); + var reached = false; + ArchiveAtPublication(source, cache, Release(), () => { + reached = true; + Assert.Equal(_binary, File.ReadAllBytes(cache)); + // A separate writer reaches the original vulnerable boundary + // deterministically, without sleeps or filesystem watcher timing. + Task.Run(() => { + var error = Record.Exception(() => { + switch (operation) { + case "write": File.WriteAllText(source, "unknown user version"); break; + case "replace": File.Move(replacement, source, true); break; + default: File.Delete(source); break; + } + }); + Assert.True(error is IOException or UnauthorizedAccessException, $"Writer was not blocked: {error}"); + }).GetAwaiter().GetResult(); + using var reader = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete); + DllManager.ValidateBytes(reader, Release()); + }); + Assert.True(reached); + Assert.False(File.Exists(source)); + Assert.Equal(_binary, File.ReadAllBytes(cache)); + Assert.Equal("unknown user version", File.ReadAllText(replacement)); + File.Move(replacement, source); + Assert.Equal("unknown user version", File.ReadAllText(source)); + } + + [Fact] + public void ArchiveNeverDeletesOnValidationOrPublicationFailure() { + if (!OperatingSystem.IsWindows()) return; + var source = Path.Combine(_dir, "approved.dll"); + var cache = Path.Combine(_dir, "cache", "approved.dll"); + File.WriteAllText(source, "unknown user version"); + Assert.Throws(() => ArchiveAtPublication(source, cache, Release(), () => Assert.True(false))); + Assert.Equal("unknown user version", File.ReadAllText(source)); + Assert.False(File.Exists(cache)); + File.WriteAllBytes(source, _binary); + // An existing directory makes the real atomic cache publication fail. + Directory.CreateDirectory(cache); + var error = Record.Exception(() => ArchiveAtPublication(source, cache, Release(), () => Assert.True(false))); + Assert.True(error is IOException or UnauthorizedAccessException, $"Cache publication unexpectedly succeeded: {error}"); + Assert.Equal(_binary, File.ReadAllBytes(source)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(cache)!, "*.tmp")); + } + + [Fact] + public async Task DefaultSelectsLatestStableSemanticVersion() { + Index(Release(version: "2.0.0"), Release(version: "10.0.0"), Release(version: "11.0.0-rc.1")); + var manager = Manager(); + await manager.RefreshAsync(); + Assert.True(manager.IsEnabled(Release(version: "10.0.0"))); + } + + [Fact] + public async Task OfflineCachedMetadataAndValidatedBinaryWork() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + _handler.Fail = true; + manager = Manager(); + await Assert.ThrowsAsync(() => manager.RefreshAsync()); + await manager.PrepareAsync(); + var files = manager.OpenForInjection(); + try { Assert.Single(files); } + finally { foreach (var file in files) file.Dispose(); } + } + + [Fact] + public async Task TamperedInstalledBinaryFailsBeforeLaunchAndAtInjection() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + var path = Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories).Single(); + File.WriteAllBytes(path, Enumerable.Repeat((byte)0, _binary.Length).ToArray()); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Throws(() => manager.OpenForInjection()); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task CorruptTruncatedAndOversizedDownloadsAreNeverInstalled(int mode) { + Index(Release()); + _handler.Binary = mode switch { + 0 => Enumerable.Repeat((byte)0, _binary.Length).ToArray(), + 1 => _binary[..^1], + _ => _binary.Concat(new byte[] { 0 }).ToArray() + }; + var manager = Manager(); + await manager.RefreshAsync(); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.False(Directory.Exists(PluginPath)); + var cache = Path.Combine(_dir, ".cache", "dlls"); + Assert.Empty(Directory.GetFiles(cache, "*.dll", SearchOption.AllDirectories)); + Assert.Empty(Directory.GetFiles(cache, "*.tmp", SearchOption.AllDirectories)); + } + + [Fact] + public async Task RemovedEnabledReleaseFailsClosed() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + Index(); + await manager.RefreshAsync(); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + } + + [Fact] + public async Task MissingRegistryDoesNotFallBackToLooseDlls() { + var manager = Manager(); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Throws(() => manager.OpenForInjection()); + } + + [Fact] + public async Task InvalidRefreshPreservesLastValidIndex() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + _handler.Index = "[null]"; + await Assert.ThrowsAsync(() => manager.RefreshAsync()); + Assert.Single(manager.Releases); + Assert.Single(Manager().Releases); + } + + [Fact] + public void InvalidMetadataAndDuplicatesRejected() { + foreach (var invalid in new[] { + Release() with { Organization = "../escape" }, Release() with { Name = "foo.dll" }, + Release() with { Version = "latest" }, Release() with { DownloadUrl = "http://example.com/a.dll" }, + Release() with { DownloadUrl = "https://example.com/a.pak" }, + Release() with { Sha256 = "bad" }, Release() with { Size = 0 } + }) { + Assert.Throws(() => DllManager.ParseIndex(JsonSerializer.Serialize(new[] { invalid }))); + } + Assert.Throws(() => DllManager.ParseIndex(JsonSerializer.Serialize(new[] { Release(), Release() }))); + Assert.Throws(() => DllManager.ParseIndex(JsonSerializer.Serialize(new[] { + Release(), Release() with { Name = "UNCHAINEDPLUGIN" } + }))); + } + + [Fact] + public async Task UnregisteredSelectionRejected() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + Assert.Throws(() => manager.SetEnabled(Release("Unregistered"), true)); + } + + [Fact] + public async Task InjectionHandlesPreventModificationOnWindows() { + if (!OperatingSystem.IsWindows()) return; + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + await manager.PrepareAsync(); + var files = manager.OpenForInjection(); + try { Assert.Throws(() => File.WriteAllText(files[0].Path, "tampered")); } + finally { foreach (var file in files) file.Dispose(); } + } + + public void Dispose() { _http.Dispose(); Directory.Delete(_dir, true); } + + private sealed class FixtureHandler : HttpMessageHandler { + public Dictionary Responses { get; } = new(); + public string Index { get; set; } = "[]"; + public byte[] Binary { get; set; } = []; + public bool Fail { get; set; } + public Action? OnBinaryRequest { get; set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + if (Fail) throw new HttpRequestException("Offline fixture"); + if (Responses.TryGetValue(request.RequestUri!.AbsoluteUri, out var content)) + return Task.FromResult(new HttpResponseMessage(content == null ? HttpStatusCode.NotFound : HttpStatusCode.OK) { + RequestMessage = request, + Content = new StringContent(content ?? "") + }); + if (request.RequestUri!.AbsoluteUri == DllManager.DefaultPackageListUrl) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { RequestMessage = request }); + if (request.RequestUri.AbsoluteUri != DllManager.DefaultIndexUrl) OnBinaryRequest?.Invoke(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + RequestMessage = request, + Content = request.RequestUri!.AbsoluteUri == DllManager.DefaultIndexUrl + ? new StringContent(Index) : new ByteArrayContent(Binary) + }); + } + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/RegistryConfigurationTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/RegistryConfigurationTests.cs new file mode 100644 index 00000000..711c1234 --- /dev/null +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/RegistryConfigurationTests.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using UnchainedLauncher.Core.Services.Dlls; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.Core.Tests.Unit.Services.Mods.Registry; + +public sealed class RegistryConfigurationTests { + private sealed class Handler : HttpMessageHandler { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + Requests.Add(request.RequestUri!.AbsoluteUri); + return Task.FromResult(new HttpResponseMessage(request.RequestUri.AbsoluteUri.EndsWith("plugin_list_index.txt") + ? System.Net.HttpStatusCode.NotFound : System.Net.HttpStatusCode.OK) { + RequestMessage = request, + Content = new StringContent("[]") + }); + } + } + + [Fact] + public void LegacyRegistriesMigrateToRootUrlsAndPreserveAggregateContents() { + const string legacy = """ + {"Kind":"AggregateModRegistry","Registries":[ + {"Kind":"GithubModRegistry","Org":"Chiv2-Community","RepoName":"C2ModRegistry"}, + {"Kind":"GithubModRegistry","Org":"Other","RepoName":"CustomRegistry"}, + {"Kind":"LocalModRegistry","PakReleaseDir":"local-mods"} + ]} + """; + var codec = ModRegistryCodec.Instance; + var loaded = codec.Deserialize(legacy); + Assert.True(loaded.Success); + var aggregate = Assert.IsType(loaded.Result); + var entries = aggregate.ModRegistries.ToArray(); + Assert.Equal(3, entries.Length); + Assert.Equal(DllManager.DefaultDatabaseUrl, Assert.IsType(entries[0]).DllDatabaseUrl); + Assert.Equal("https://raw.githubusercontent.com/Other/CustomRegistry/refs/heads/main/dll_db", + Assert.IsType(entries[1]).DllDatabaseUrl); + Assert.Equal(Path.GetFullPath("local-mods"), Assert.IsType(entries[2]).RegistryPath); + + var migrated = codec.Serialize(aggregate); + using var document = JsonDocument.Parse(migrated); + foreach (var entry in document.RootElement.GetProperty("Registries").EnumerateArray().Take(2)) { + Assert.True(entry.TryGetProperty("BaseUrl", out var url)); + Assert.EndsWith("/refs/heads/main", url.GetString()); + Assert.False(entry.TryGetProperty("Org", out _)); + Assert.False(entry.TryGetProperty("RepoName", out _)); + } + Assert.Equal(migrated, codec.Serialize(codec.Deserialize(migrated).Result!)); + } + + [Fact] + public async Task MigratedAlternateRegistryDrivesDllRefreshWithoutOfficialRegistryRequests() { + var directory = Path.Combine(Path.GetTempPath(), "registry-migration-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try { + var registry = Assert.IsType(ModRegistryCodec.Instance.Deserialize( + """{"Kind":"GithubModRegistry","Org":"Other","RepoName":"CustomRegistry"}""").Result); + var handler = new Handler(); + using var http = new HttpClient(handler); + var manager = new DllManager(Path.Combine(directory, "Plugins"), Path.Combine(directory, "state.json"), http, + registryUrls: () => [registry.DllDatabaseUrl]); + await manager.RefreshAsync(); + Assert.Equal(new[] { + "https://raw.githubusercontent.com/Other/CustomRegistry/refs/heads/main/dll_db/plugin_list_index.txt", + "https://raw.githubusercontent.com/Other/CustomRegistry/refs/heads/main/dll_db/index.json" + }, handler.Requests); + Assert.Empty(manager.Releases); + } + finally { Directory.Delete(directory, true); } + } + + [Theory] + [InlineData("main")] + [InlineData("refs/heads/testing")] + [InlineData("refs/heads/feature/registry")] + public void RootConfigurationPreservesRevisionAndDerivesBothDatabases(string revision) { + var root = $"https://raw.githubusercontent.com/Other/CustomRegistry/{revision}"; + var registry = new GithubModRegistry(root + "/"); + Assert.Equal(root, registry.RegistryBaseUrl); + Assert.Equal(root + "/package_db", registry.PackageDBBaseUrl); + Assert.Equal(root + "/dll_db", registry.DllDatabaseUrl); + var codec = ModRegistryCodec.Instance; + var restored = Assert.IsType(codec.Deserialize(codec.Serialize(registry)).Result); + Assert.Equal(root, restored.RegistryBaseUrl); + restored.Organization = "NewOrg"; + restored.RepoName = "NewRepo"; + Assert.Equal($"https://raw.githubusercontent.com/NewOrg/NewRepo/{revision}", restored.RegistryBaseUrl); + } + + [Theory] + [InlineData("http://raw.githubusercontent.com/Org/Repo/main")] + [InlineData("https://raw.githubusercontent.com/Org/Repo")] + [InlineData("https://raw.githubusercontent.com/Org/Repo/main/package_db")] + [InlineData("https://raw.githubusercontent.com/Org/Repo/main/dll_db")] + [InlineData("https://raw.githubusercontent.com/Org/Repo/main?token=secret")] + [InlineData("https://example.test/Org/Repo/main")] + public void InvalidOrDatabaseSpecificRootsAreRejected(string root) { + Assert.Throws(() => new GithubModRegistry(root)); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs new file mode 100644 index 00000000..01cab1a9 --- /dev/null +++ b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs @@ -0,0 +1,109 @@ +using LanguageExt; +using LanguageExt.Common; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using ManagedPakDirectory = UnchainedLauncher.Core.Services.Mods.PakDir; + +namespace UnchainedLauncher.Core.Tests.Unit.Services.PakDir; + +public sealed class PakOrderCollisionTests : IDisposable { + private readonly string _dir = Path.Combine(Path.GetTempPath(), "pak-order-" + Guid.NewGuid().ToString("N")); + private readonly ReleaseCoordinates _dependency = new("org", "Dependency", "1.0.0"); + private readonly ReleaseCoordinates _dependent = new("org", "Dependent", "1.0.0"); + private readonly Dictionary _unmanaged = new(); + private int _writes; + + public PakOrderCollisionTests() { + Directory.CreateDirectory(_dir); + File.WriteAllText(Path.Combine(_dir, "pakchunk0-WindowsNoEditor.sig"), "signature"); + } + + private void Occupy(string name) { + foreach (var file in new[] { name, Path.ChangeExtension(name, ".sig") }) { + var bytes = System.Text.Encoding.UTF8.GetBytes("unmanaged " + file); + _unmanaged.Add(file, bytes); + File.WriteAllBytes(Path.Combine(_dir, file), bytes); + } + } + + private ValueTask>> Install(ManagedPakDirectory dir, params ReleaseCoordinates[] coords) => + dir.InstallModSet(coords.Select(c => new ModInstallRequest(c, path => { + _writes++; + return PakDirTest.StaticMkWriter(path); + })), Option.None).ToListAsync(); + + private void AssertUnmanagedUnchanged() { + foreach (var (name, bytes) in _unmanaged) Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(_dir, name))); + } + + [Theory] + [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"); + 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); + 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)); + Assert.Equal(2, _writes); + Assert.Equal(new[] { _dependent, _dependency }, dir.ManagedPaks.OrderBy(p => p.PakFileName, StringComparer.OrdinalIgnoreCase).Select(p => p.Coordinates)); + var moved = dir.ManagedPaks.Single(p => p.Coordinates == _dependency); + Assert.Equal("qz__-__Dependency (2).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"))); + 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)); + Assert.Equal(names, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName)); + Assert.Equal(2, _writes); + + var freshPath = Path.Combine(_dir, "fresh"); + Directory.CreateDirectory(freshPath); + 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)); + 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)).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. + AssertUnmanagedUnchanged(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CollisionRelocationFailureReturnsErrorWithoutPublishingNewInventory(bool signatureFailure) { + Occupy("q__-__Dependency.pak"); + Occupy("qz__-__Dependency.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); + Assert.Contains(results, r => r.IsLeft); + Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); + AssertUnmanagedUnchanged(); + } + + 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 new file mode 100644 index 00000000..d39861e0 --- /dev/null +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs @@ -0,0 +1,194 @@ +using LanguageExt; +using LanguageExt.Common; +using Moq; +using System.Diagnostics; +using System.Security.Cryptography; +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.UnrealModScanner.JsonModels; +using ManagedPakDirectory = UnchainedLauncher.Core.Services.Mods.PakDir; + +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 sealed class ImmediateContext : SynchronizationContext { + public override void Post(SendOrPostCallback callback, object? state) { + var previous = Current; + SetSynchronizationContext(this); + try { callback(state); } + finally { SetSynchronizationContext(previous); } + } + } + + public PakLaunchPreparationTests() { + Directory.CreateDirectory(_dir); + File.WriteAllText(Path.Combine(_dir, "pakchunk0-WindowsNoEditor.sig"), "signature"); + } + + private ModManager Manager(ManagedPakDirectory pakDir, string installedName = Installed) { + var info = new ModInfo("https://github.com/Chiv2-Community/Unchained-Mods", "Unchained-Mods", "test", null, null, [], [], []); + var hash = Convert.ToHexString(SHA512.HashData(File.ReadAllBytes(Path.Combine(_dir, installedName)))).ToLowerInvariant(); + var release = new Release(_coords.Version, hash, "https://example.test/mod.pak", DateTime.UtcNow, + info, new AssetCollections([], [], [], [], []), null); + return new ModManager(Mock.Of(), pakDir, [_coords], [new Mod(info, [release])]); + } + + private static LaunchOptions Options() => new([], Option.None, "", false, false, + Option.None, Option.None); + + private sealed class Capture : IProcessLauncher { + public string Executable => "test.exe"; + public int Calls { get; private set; } + public Action? VerifyPrepared { get; set; } + public Either Launch(string workingDirectory, string args) { + Calls++; + VerifyPrepared?.Invoke(); + return Prelude.Left(new LaunchFailed(Executable, args, Error.New("test boundary"))); + } + } + + [Fact] + public async Task OccupiedUnmanagedNameStaysUntouchedAcrossRepeatedPreparations() { + SynchronizationContext.SetSynchronizationContext(new ImmediateContext()); + File.WriteAllText(Path.Combine(_dir, Preferred), "unmanaged bytes"); + File.WriteAllText(Path.Combine(_dir, Installed), "registered bytes"); + var pakDir = new ManagedPakDirectory(_dir, [new ManagedPak(_coords, Installed, 0)]); + var dialogue = new Mock(); + dialogue.Setup(d => d.DisplayProgress(It.IsAny())).Returns(() => { }); + IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(Manager(pakDir), dialogue.Object); + var preparer = installer.Sub(SigPreparer.Create(pakDir, dialogue.Object)); + var capture = new Capture { VerifyPrepared = () => Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(_dir, Installed), ".sig"))) }; + var launcher = new UnchainedChivalry2Launcher(preparer, capture, _dir, new NullInjector()); + await launcher.Launch(Options()); + await launcher.Launch(Options()); + Assert.Equal(2, capture.Calls); + Assert.Equal("unmanaged bytes", File.ReadAllText(Path.Combine(_dir, Preferred))); + Assert.Equal("registered bytes", File.ReadAllText(Path.Combine(_dir, Installed))); + dialogue.Verify(d => d.DisplayMessage(It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedPakMoveCancelsLaunchAndClosesProgress(bool vanilla) { + SynchronizationContext.SetSynchronizationContext(new ImmediateContext()); + File.WriteAllText(Path.Combine(_dir, Installed), "registered bytes"); + Directory.CreateDirectory(Path.Combine(_dir, Preferred)); // Deterministic destination failure. + var pakDir = new ManagedPakDirectory(_dir, [new ManagedPak(_coords, Installed, 0)]); + var closed = false; + var dialogue = new Mock(); + dialogue.Setup(d => d.DisplayProgress(It.IsAny())).Returns(() => closed = true); + IChivalry2LaunchPreparer preparer = new Chivalry2ModsInstaller(Manager(pakDir), dialogue.Object); + var capture = new Capture(); + IChivalry2Launcher launcher = vanilla + ? new Chivalry2Launcher(preparer, capture, _dir) + : new UnchainedChivalry2Launcher(preparer, capture, _dir, new NullInjector()); + Assert.True((await launcher.Launch(Options())).IsLeft); + Assert.Equal(0, capture.Calls); + Assert.True(closed); + dialogue.Verify(d => d.DisplayMessage(It.Is(text => text.Contains("Launch cancelled"))), Times.Once); + dialogue.Verify(d => d.DisplayYesNoMessage(It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal("registered bytes", File.ReadAllText(Path.Combine(_dir, Installed))); + } + + [Fact] + public async Task ObsoleteCollisionPrefixMoveFailureCancelsLaunchAndClosesProgress() { + SynchronizationContext.SetSynchronizationContext(new ImmediateContext()); + const string stale = "qz__-__Unchained-Mods (1).pak"; + File.WriteAllText(Path.Combine(_dir, stale), "registered bytes"); + File.WriteAllText(Path.Combine(_dir, Preferred), "unmanaged bytes"); + Directory.CreateDirectory(Path.Combine(_dir, Installed)); + var pakDir = new ManagedPakDirectory(_dir, [new ManagedPak(_coords, stale, 0)]); + var closed = false; + var dialogue = new Mock(); + dialogue.Setup(d => d.DisplayProgress(It.IsAny())).Returns(() => closed = true); + var capture = new Capture(); + var launcher = new UnchainedChivalry2Launcher(new Chivalry2ModsInstaller(Manager(pakDir, stale), dialogue.Object), capture, _dir, new NullInjector()); + Assert.True((await launcher.Launch(Options())).IsLeft); + Assert.Equal(0, capture.Calls); + Assert.True(closed); + Assert.Equal(stale, Assert.Single(pakDir.ManagedPaks).PakFileName); + Assert.Equal("registered bytes", File.ReadAllText(Path.Combine(_dir, stale))); + Assert.Equal("unmanaged bytes", File.ReadAllText(Path.Combine(_dir, Preferred))); + dialogue.Verify(d => d.DisplayMessage(It.Is(text => text.Contains("Launch cancelled"))), Times.Once); + dialogue.Verify(d => d.DisplayYesNoMessage(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task FailedPakRemovalBlocksLaunchAndPreservesPak() { + SynchronizationContext.SetSynchronizationContext(new ImmediateContext()); + File.WriteAllText(Path.Combine(_dir, Installed), "registered bytes"); + File.WriteAllText(Path.Combine(_dir, Preferred), "unmanaged bytes"); + var stale = new ReleaseCoordinates("org", "stale", "v1.0.0"); + File.WriteAllText(Path.Combine(_dir, "stale.pak"), "stale bytes"); + Directory.CreateDirectory(Path.Combine(_dir, "stale.sig")); + var pakDir = new ManagedPakDirectory(_dir, [new ManagedPak(_coords, Installed, 0), new ManagedPak(stale, "stale.pak", 1)]); + var dialogue = new Mock(); + dialogue.Setup(d => d.DisplayProgress(It.IsAny())).Returns(() => { }); + var capture = new Capture(); + var launcher = new UnchainedChivalry2Launcher(new Chivalry2ModsInstaller(Manager(pakDir), dialogue.Object), capture, _dir, new NullInjector()); + Assert.True((await launcher.Launch(Options())).IsLeft); + Assert.Equal(0, capture.Calls); + Assert.Equal("stale bytes", File.ReadAllText(Path.Combine(_dir, "stale.pak"))); + dialogue.Verify(d => d.DisplayMessage(It.Is(text => text.Contains("Launch cancelled"))), Times.Once); + } + + [Fact] + public async Task LaunchWaitsForEveryStageAndPassesPreparedOptions() { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + IChivalry2LaunchPreparer first = Chivalry2LaunchPreparer.Create(async _ => { + started.SetResult(); + return await finish.Task; + }); + var stages = 0; + var prepared = Options() with { LaunchArgs = "-prepared" }; + var chain = first.AndThen(opts => { Assert.Same(prepared, opts); stages++; return Task.FromResult(Prelude.Some(opts)); }); + var capture = new Capture { VerifyPrepared = () => Assert.Equal(1, stages) }; + var launcher = new UnchainedChivalry2Launcher(chain, capture, _dir, new NullInjector()); + var launch = launcher.Launch(Options()); + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(0, capture.Calls); + Assert.Equal(0, stages); + finish.SetResult(Prelude.Some(prepared)); + await launch.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(1, capture.Calls); + } + + [Theory] + [InlineData(1, "There were errors while installing releases:\n failure 1\nLaunch cancelled. Resolve the pak setup errors before launching.")] + [InlineData(2, "There were errors while installing releases:\n failure 1\n\nfailure 2\nLaunch cancelled. Resolve the pak setup errors before launching.")] + public async Task InstallationErrorsKeepDiagnosticOrderAndCancelLaunch(int count, string expected) { + SynchronizationContext.SetSynchronizationContext(new ImmediateContext()); + var errors = Enumerable.Range(1, count).Select(index => Error.New($"failure {index}")).ToArray(); + async IAsyncEnumerable> Results() { + await Task.CompletedTask; + foreach (var error in errors) yield return Prelude.Left(error); + } + var pakDir = new Mock(); + pakDir.Setup(p => p.InstallModSet(It.IsAny>(), + It.IsAny>())).Returns(Results); + var manager = new ModManager(Mock.Of(), pakDir.Object, [], []); + var closed = false; + var dialogue = new Mock(); + dialogue.Setup(d => d.DisplayProgress(It.IsAny())).Returns(() => closed = true); + var capture = new Capture(); + var launcher = new UnchainedChivalry2Launcher(new Chivalry2ModsInstaller(manager, dialogue.Object), capture, _dir, new NullInjector()); + Assert.True((await launcher.Launch(Options())).IsLeft); + Assert.Equal(0, capture.Calls); + Assert.True(closed); + dialogue.Verify(d => d.DisplayMessage(expected), Times.Once); + dialogue.Verify(d => d.DisplayYesNoMessage(It.IsAny(), It.IsAny()), Times.Never); + } + + public void Dispose() => Directory.Delete(_dir, true); +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/JsonModels/ModMetadata/V4.cs b/UnchainedLauncher.Core/JsonModels/ModMetadata/V4.cs index 40d93a00..156b017c 100644 --- a/UnchainedLauncher.Core/JsonModels/ModMetadata/V4.cs +++ b/UnchainedLauncher.Core/JsonModels/ModMetadata/V4.cs @@ -52,8 +52,14 @@ public record ModInfo( [property: JsonPropertyName("icon_url")] string? IconUrl, [property: JsonPropertyName("image_urls")] List ImageUrls, [property: JsonPropertyName("authors")] List Authors, - [property: JsonPropertyName("dependencies")] List Dependencies + [property: JsonPropertyName("dependencies"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List? Dependencies = null, + [property: JsonPropertyName("mod_dependencies"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List? ModDependencies = null, + [property: JsonPropertyName("plugin_dependencies"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List? PluginDependencies = null ) { + [JsonIgnore] + public List EffectiveModDependencies => (ModDependencies ?? []).Concat(Dependencies ?? []).Distinct().ToList(); + [JsonIgnore] + public List EffectivePluginDependencies => PluginDependencies ?? []; public string? NonEmptyIconUrl => string.IsNullOrWhiteSpace(IconUrl) ? null : IconUrl; public string Organization => RepoUrl.Split('/')[^2]; public string RepoName => RepoUrl.Split('/')[^1]; diff --git a/UnchainedLauncher.Core/Services/Dlls/DependencyValidator.cs b/UnchainedLauncher.Core/Services/Dlls/DependencyValidator.cs new file mode 100644 index 00000000..ba3ed77d --- /dev/null +++ b/UnchainedLauncher.Core/Services/Dlls/DependencyValidator.cs @@ -0,0 +1,57 @@ +using UnchainedLauncher.Core.JsonModels.ModMetadata; + +namespace UnchainedLauncher.Core.Services.Dlls { + public static class DependencyValidator { + public static void Validate(IReadOnlyList mods, IReadOnlyList plugins) { + var pluginsByMod = mods + .GroupBy(mod => mod.Info.RepoUrl, StringComparer.OrdinalIgnoreCase) + .Select(group => ( + Info: group.First().Info, + MissingDependencies: group + .SelectMany(mod => mod.Info.EffectivePluginDependencies) + .Where(dependency => !plugins.Any(plugin => + dependency.RepoUrl.Equals($"https://github.com/{plugin.Id}", StringComparison.OrdinalIgnoreCase) + && PluginInfo.MatchesVersion(plugin.Version, dependency.Version))) + .Distinct() + .ToList())) + .Where(group => group.MissingDependencies.Count != 0) + .ToArray(); + + if (pluginsByMod.Length != 0) { + var modText = + pluginsByMod.Length == 1 + ? "One mod requires" + : "Some mods require"; + + var pluginText = + pluginsByMod.SelectMany(group => group.MissingDependencies).Distinct().Count() == 1 + ? "a DLL plugin" + : "DLL plugins"; + + var details = pluginsByMod + .OrderBy(group => group.Info.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(group => group.Info.RepoUrl, StringComparer.OrdinalIgnoreCase) + .Select(group => $"{group.Info.Name}:\n" + string.Join("\n", group.MissingDependencies + .OrderBy(dependency => dependency.RepoName, StringComparer.OrdinalIgnoreCase) + .ThenBy(dependency => dependency.RepoUrl, StringComparer.OrdinalIgnoreCase) + .ThenBy(dependency => dependency.Version, StringComparer.OrdinalIgnoreCase) + .Select(dependency => $"\t- {dependency.RepoName} {dependency.Version}"))); + + throw new InvalidOperationException($"{modText} {pluginText}. Enable them in the Plugin Manager.\n\n{string.Join("\n\n", details)}"); + } + + var missingMod = plugins + .SelectMany(plugin => (plugin.Info?.RequiredMods ?? []) + .Select(dependency => (Plugin: plugin, Dependency: dependency))) + .FirstOrDefault(requirement => !mods.Any(mod => + requirement.Dependency.RepoUrl.Equals(mod.Info.RepoUrl, StringComparison.OrdinalIgnoreCase) + && mod.Version != null + && PluginInfo.MatchesVersion(mod.Version.ToString(), requirement.Dependency.Version))); + + if (missingMod.Plugin != null) { + throw new InvalidOperationException( + $"Enable a compatible mod for {missingMod.Plugin.Id}: {missingMod.Dependency.RepoUrl} {missingMod.Dependency.Version}"); + } + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs new file mode 100644 index 00000000..dba148a3 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs @@ -0,0 +1,484 @@ +using Microsoft.Win32.SafeHandles; +using Semver; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace UnchainedLauncher.Core.Services.Dlls { + /// Only explicitly enabled, registry-backed releases may reach the injector. + public sealed class DllManager { + public const string DefaultDatabaseUrl = "https://raw.githubusercontent.com/Chiv2-Community/C2ModRegistry/refs/heads/main/dll_db"; + public const string DefaultIndexUrl = DefaultDatabaseUrl + "/index.json"; + public const string DefaultPackageListUrl = DefaultDatabaseUrl + "/plugin_list_index.txt"; + public const string DefaultPluginId = "Chiv2-Community/UnchainedPlugin"; + private readonly HttpClient _http; + private readonly string _root; + private readonly string _cacheRoot; + private readonly string _statePath; + private readonly string _indexPath; + private readonly Func> _registryUrls; + private string? _approvedSources; + private readonly Dictionary _enabled; + private readonly object _gate = new(); + private bool _defaultPending; + private readonly Func> _modReleases; + public IReadOnlyList Releases { get; private set; } = []; + public event EventHandler? SelectionChanged; + + 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"); + _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"; + _http = http; + _defaultPending = !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") && + File.ReadAllText(_indexPath + ".sources") == SourceSignature()) { + _approvedSources = SourceSignature(); + Releases = ParseIndex(File.ReadAllText(_indexPath)); + EnableDefault(); + } + } + + public static IReadOnlyList ParseIndex(string json) { + var entries = JsonSerializer.Deserialize>(json) + ?? throw new InvalidDataException("Missing DLL index."); + return ValidateReleases(entries); + } + + private static IReadOnlyList ValidateReleases(IReadOnlyList entries) { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var release in entries) { + if (release == null) throw new InvalidDataException("Null DLL release."); + release.Validate(); + if (!keys.Add(release.Key)) throw new InvalidDataException("Duplicate DLL release."); + } + return entries; + } + + public async Task RefreshAsync() { + var sources = RegistryUrls(); + var signature = JsonSerializer.Serialize(sources); + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var source in sources) { + foreach (var release in await ReadRegistryAsync(source)) { + if (merged.TryGetValue(release.Key, out var old) && Canonical(old) != Canonical(release)) + throw new InvalidDataException($"Conflicting DLL registry records: {release.Key}"); + merged[release.Key] = release; + } + } + var releases = ValidateReleases(merged.Values.ToArray()); + var json = JsonSerializer.Serialize(releases); + 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); + Releases = releases; + _approvedSources = signature; + EnableDefault(); + } + } + + private string[] RegistryUrls() => _registryUrls().Select(url => { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || + uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0) + throw new InvalidDataException("Invalid DLL registry URL."); + return uri.AbsoluteUri.TrimEnd('/'); + }).Distinct(StringComparer.Ordinal).OrderBy(url => url, StringComparer.Ordinal).ToArray(); + + private string SourceSignature() => JsonSerializer.Serialize(RegistryUrls()); + + private async Task> ReadRegistryAsync(string databaseUrl) { + // 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"); + if (listResponse.StatusCode == HttpStatusCode.NotFound) { + using var legacyResponse = await _http.GetAsync(databaseUrl + "/index.json"); + // Mod-only registries have neither format. + return legacyResponse.StatusCode == HttpStatusCode.NotFound ? [] : ParseIndex(await ReadResponse(legacyResponse)); + } + var packages = (await ReadResponse(listResponse)).Split('\n').Select(line => line.Trim()).Where(line => line.Length != 0); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + 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"); + foreach (var release in ParsePackage(await ReadResponse(packageResponse), package)) + merged.Add(release.Key, release); + } + return merged.Values.ToArray(); + } + + public static IReadOnlyList ParsePackage(string json, string identifier) { + if (!Regex.IsMatch(identifier, "^[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$")) + throw new InvalidDataException("Invalid plugin package identifier."); + var parts = identifier.Split('/'); + var data = JsonSerializer.Deserialize(json) ?? throw new InvalidDataException("Missing plugin metadata."); + if (data.LatestReleaseInfo == null || data.Releases == null || data.Releases.Count == 0) + throw new InvalidDataException("Incomplete plugin metadata."); + data.LatestReleaseInfo.Validate(); + if (data.LatestReleaseInfo.RepoUrl != null && !data.LatestReleaseInfo.RepoUrl.Equals($"https://github.com/{identifier}", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Plugin repository mismatch."); + var latest = data.Releases.OrderByDescending(release => release?.ReleaseDate).FirstOrDefault(release => release?.Info != null)?.Info; + if (latest == null || CanonicalInfo(latest) != CanonicalInfo(data.LatestReleaseInfo)) + throw new InvalidDataException("Plugin latest-release metadata mismatch."); + // Upstream permits null info/date/filename for previously approved + // historical releases inside an otherwise metadata-backed package. + return ValidateReleases(data.Releases.Select(record => { + if (record == null) throw new InvalidDataException("Null plugin release."); + return record.ToDllRelease(parts[0], parts[1]); + }).ToArray()); + } + + private static async Task ReadResponse(HttpResponseMessage response) { + response.EnsureSuccessStatusCode(); + if (response.RequestMessage?.RequestUri?.Scheme != Uri.UriSchemeHttps) + throw new InvalidDataException("Insecure DLL metadata redirect."); + return await response.Content.ReadAsStringAsync(); + } + + private static string Canonical(DllRelease release) => JsonSerializer.Serialize(release with { + Organization = release.Organization.ToLowerInvariant(), + Name = release.Name.ToLowerInvariant(), + Tag = release.Tag ?? release.Version, + Info = release.Info == null ? null : NormalizeInfo(release.Info) + }); + + private static PluginInfo NormalizeInfo(PluginInfo info) => info with { + PluginDependencies = info.RequiredPlugins, + ModDependencies = info.RequiredMods, + ImageUrls = info.ImageUrls ?? [], + Schema = null + }; + + private static string CanonicalInfo(PluginInfo info) => JsonSerializer.Serialize(NormalizeInfo(info)); + + private void EnableDefault() { + if (!_defaultPending) return; + var latest = Releases.Where(r => r.Id == DefaultPluginId) + .Where(r => !SemVersion.Parse(r.Version, SemVersionStyles.Strict).IsPrerelease) + .OrderByDescending(r => SemVersion.Parse(r.Version, SemVersionStyles.Strict), + SemVersion.PrecedenceComparer).FirstOrDefault(); + if (latest == null) return; + _enabled[latest.Id] = latest.Version; + Save(); + _defaultPending = false; + } + + public bool IsEnabled(DllRelease release) { + lock (_gate) return _enabled.TryGetValue(release.Id, out var version) && version == release.Version; + } + + public IReadOnlyDictionary EnabledSelections { + get { lock (_gate) return new Dictionary(_enabled, StringComparer.OrdinalIgnoreCase); } + } + + // Revoking a selection never requires registry approval and never touches DLL files. + public void Disable(string id) { + lock (_gate) { + if (!_enabled.Remove(id, out var previous)) return; + try { Save(); } + catch { _enabled[id] = previous; throw; } + } + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + + public void SetEnabled(DllRelease release, bool enabled) { + if (!enabled) { + if (IsEnabled(release)) Disable(release.Id); + return; + } + lock (_gate) { + EnsureCurrentSources(); + // Refresh reparses metadata collections, so record equality alone rejects + // unchanged releases held by existing cards. Approval still requires the + // complete current registry record, not just the same id/version. + var registered = Releases.SingleOrDefault(candidate => candidate.Key.Equals(release.Key, StringComparison.OrdinalIgnoreCase)); + if (registered == null || Canonical(registered) != Canonical(release)) + throw new InvalidOperationException("Unregistered DLL. Refresh Plugin List and select a current release."); + release = registered; + var previous = _enabled.GetValueOrDefault(release.Id); + _enabled[release.Id] = release.Version; + try { Save(); } + catch { + if (previous == null) _enabled.Remove(release.Id); + else _enabled[release.Id] = previous; + throw; + } + } + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + + private void Save() => WriteAtomic(_statePath, JsonSerializer.Serialize(_enabled)); + + private static void WriteAtomic(string path, string contents) { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + File.WriteAllText(temp, contents); + File.Move(temp, path, true); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + + private DllRelease[] SelectedReleases(bool server = false) { + lock (_gate) { + var selected = EnabledForTarget(server); + DependencyValidator.Validate(_modReleases(), selected); + var ordered = new List(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); + void Visit(DllRelease release) { + if (visited.Contains(release.Id)) return; + if (!visiting.Add(release.Id)) throw new InvalidOperationException("Circular plugin dependency."); + foreach (var dependency in release.Info?.RequiredPlugins ?? []) { + var url = new Uri(dependency.RepoUrl); + var id = url.AbsolutePath.Trim('/'); + var required = selected.SingleOrDefault(candidate => candidate.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + if (required == null || !PluginInfo.MatchesVersion(required.Version, dependency.Version)) + throw new InvalidOperationException($"Enable a compatible registered dependency first: {id} {dependency.Version}"); + Visit(required); + } + visiting.Remove(release.Id); + visited.Add(release.Id); + ordered.Add(release); + } + foreach (var release in selected) Visit(release); + return ordered.ToArray(); + } + } + + private static bool AppliesToTarget(DllRelease release, bool server) => + release.Info?.Target is null or "shared" || release.Info.Target == (server ? "server" : "client"); + + private DllRelease[] EnabledForTarget(bool? server) { + EnsureCurrentSources(); + if (_defaultPending) throw new InvalidOperationException("The default plugin is not registered. Refresh the DLL index before launching."); + return _enabled.Select(pair => Releases.SingleOrDefault(r => r.Id.Equals(pair.Key, StringComparison.OrdinalIgnoreCase) && r.Version == pair.Value) + ?? throw new InvalidOperationException($"Enabled DLL is no longer registered: {pair.Key}:{pair.Value}")) + .Where(release => server == null || AppliesToTarget(release, server.Value)).ToArray(); + } + + public IReadOnlyList<(DllRelease Current, DllRelease Latest)> GetUpdateCandidates(bool? server, bool allowUnstable) { + lock (_gate) { + var updates = new List<(DllRelease, DllRelease)>(); + foreach (var current in EnabledForTarget(server)) { + var latest = Releases.Where(release => release.Id.Equals(current.Id, StringComparison.OrdinalIgnoreCase) + && (server == null || AppliesToTarget(release, server.Value)) + && (allowUnstable || !SemVersion.Parse(release.Version, SemVersionStyles.Strict).IsPrerelease)) + .OrderByDescending(release => SemVersion.Parse(release.Version, SemVersionStyles.Strict), + SemVersion.PrecedenceComparer).FirstOrDefault(); + if (latest != null && SemVersion.Parse(latest.Version, SemVersionStyles.Strict) + .ComparePrecedenceTo(SemVersion.Parse(current.Version, SemVersionStyles.Strict)) > 0) + updates.Add((current, latest)); + } + return updates; + } + } + + private void EnsureCurrentSources() { + if (_approvedSources != SourceSignature()) + throw new InvalidOperationException("DLL registry settings changed. Refresh Plugin Manager before launching."); + } + + 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 GetCachePath(DllRelease release) => Path.Combine(_cacheRoot, release.Sha256, release.EffectiveFileName); + + private static bool MatchesFile(string path, DllRelease release) { + using var stream = File.OpenRead(path); + try { ValidateBytes(stream, release); return true; } + catch (InvalidDataException) { return false; } + } + + private DllRelease? ManagedReleaseAt(string path) => Releases.FirstOrDefault(release => + (GetPath(release).Equals(path, StringComparison.OrdinalIgnoreCase) + || GetLegacyPath(release).Equals(path, StringComparison.OrdinalIgnoreCase)) + && MatchesFile(path, release)); + + public static void ValidateBytes(Stream stream, DllRelease release) { + release.Validate(); + stream.Position = 0; + if (stream.Length != release.Size || + !Convert.ToHexString(SHA256.HashData(stream)).Equals(release.Sha256, StringComparison.OrdinalIgnoreCase)) { + throw new InvalidDataException($"DLL size or SHA-256 mismatch: {release.Key}"); + } + stream.Position = 0; + } + + public Task PrepareAsync(bool server = false, bool allowDownloads = true) => + PrepareAsync(server, allowDownloads, null); + + private async Task PrepareAsync(bool server, bool allowDownloads, Action? activePublishing) { + var selected = SelectedReleases(server); + // Fill and validate the cache before changing any installed DLLs. + foreach (var release in selected) { + 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); + if (File.Exists(cache)) { + using var existing = File.OpenRead(cache); + ValidateBytes(existing, release); + continue; + } + var installed = new[] { path, GetLegacyPath(release) } + .FirstOrDefault(candidate => File.Exists(candidate) && MatchesFile(candidate, release)); + if (installed != null) { + CopyValidated(installed, cache, release); + continue; + } + 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); + 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 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) { + total += count; + if (total > release.Size) throw new InvalidDataException("DLL exceeds registered size."); + await target.WriteAsync(buffer.AsMemory(0, count)); + } + await target.FlushAsync(); + target.Position = 0; + ValidateBytes(target, release); + } + File.Move(temp, cache, true); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + lock (_gate) { + 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()) { + var release = ManagedReleaseAt(path); + if (release == null || (active.Contains(path) && selected.Any(candidate => + GetPath(candidate).Equals(path, StringComparison.OrdinalIgnoreCase) && MatchesFile(path, candidate)))) + continue; + CopyValidated(path, GetCachePath(release), release, archive: true); + } + } + foreach (var release in selected) { + var path = GetPath(release); + if (!File.Exists(path)) CopyValidated(GetCachePath(release), path, release, + beforePublish: activePublishing == null ? null : () => activePublishing(path), overwrite: false); + using var installed = File.OpenRead(path); + ValidateBytes(installed, release); + } + } + } + + private static void CopyValidated(string source, string destination, DllRelease release, + bool archive = false, Action? cachePublished = null, Action? beforePublish = null, bool overwrite = true) { + // Unix pathname unlink cannot condition deletion on an open file identity. + // Keep portable validation/copying, but never silently use unsafe archival. + if (archive && !OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("Safe managed DLL archival requires Windows file handles."); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var temp = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + 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); + } + 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(); + if (archive) { + byte delete = 1; // FILE_DISPOSITION_INFO.DeleteFile (BOOLEAN) + if (!SetFileInformationByHandle(input.SafeFileHandle, 4, ref delete, 1)) + throw new IOException("Could not delete the validated managed DLL.", new Win32Exception(Marshal.GetLastWin32Error())); + } + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + + private static FileStream OpenForArchive(string path) { + // GENERIC_READ | DELETE, FILE_SHARE_READ, OPEN_EXISTING. Do not grant + // FILE_SHARE_WRITE/DELETE: the validated identity stays protected until + // deletion is marked on this very handle and the handle closes. + var handle = CreateFileW(path, 0x80010000, 1, IntPtr.Zero, 3, 0, IntPtr.Zero); + if (handle.IsInvalid) { + var error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new IOException("Could not protect the managed DLL for archival.", new Win32Exception(error)); + } + try { return new FileStream(handle, FileAccess.Read); } + catch { handle.Dispose(); throw; } + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security, + uint creation, uint flags, IntPtr template); + + [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle(SafeFileHandle handle, int informationClass, + ref byte information, uint size); + + // Read handles deny writes/deletion on Windows until injection completes. + public IReadOnlyList OpenForInjection(bool server = false) { + var files = new List(); + try { + foreach (var release in SelectedReleases(server)) { + var path = GetPath(release); + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + try { ValidateBytes(stream, release); } + catch { stream.Dispose(); throw; } + files.Add(new ValidatedDll(path, stream)); + } + return files; + } + catch { + foreach (var file in files) file.Dispose(); + throw; + } + } + } + + public sealed class ValidatedDll : IDisposable { + public string Path { get; } + private readonly Stream _stream; + internal ValidatedDll(string path, Stream stream) { Path = path; _stream = stream; } + public void Dispose() => _stream.Dispose(); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs b/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs new file mode 100644 index 00000000..3b3bdd37 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs @@ -0,0 +1,138 @@ +using Semver; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.JsonModels.ModMetadata; + +namespace UnchainedLauncher.Core.Services.Dlls { + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public sealed record DllRelease( + [property: JsonPropertyName("organization")] string Organization, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("download_url")] string DownloadUrl, + [property: JsonPropertyName("sha256")] string Sha256, + [property: JsonPropertyName("size")] long Size, + [property: JsonPropertyName("info"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] PluginInfo? Info = null, + [property: JsonPropertyName("tag"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Tag = null, + [property: JsonPropertyName("release_date"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] DateTimeOffset? ReleaseDate = null, + [property: JsonPropertyName("release_notes_markdown"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ReleaseNotesMarkdown = null, + [property: JsonPropertyName("dll_file_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? DllFileName = null) { + [JsonIgnore] + public string Id => $"{Organization}/{Name}"; + [JsonIgnore] + public string Key => $"{Id}:{Version}"; + [JsonIgnore] + public string GithubReleaseUrl => $"https://github.com/{Id}/releases/tag/{Uri.EscapeDataString(Tag ?? Version)}"; + [JsonIgnore] + public string EffectiveFileName => DllFileName ?? Name + ".dll"; + + public void Validate() { + Info?.Validate(); + if (Info?.RepoUrl != null && !Info.RepoUrl.Equals($"https://github.com/{Id}", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Plugin repository mismatch."); + if (Tag != null && SemVersion.Parse(Tag, SemVersionStyles.AllowV).ToString() != Version) + throw new InvalidDataException("Plugin tag/version mismatch."); + if (DllFileName != null && (!DllFileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || DllFileName.IndexOfAny(['/', '\\', ':', '<', '>', '"', '|', '?', '*']) >= 0 || DllFileName.Any(char.IsControl))) + throw new InvalidDataException("Invalid DLL asset filename."); + if (!Regex.IsMatch(Organization ?? "", "^[A-Za-z0-9_-]+$") || + !Regex.IsMatch(Name ?? "", "^[A-Za-z0-9_-]+$") || + !SemVersion.TryParse(Version, SemVersionStyles.Strict, out _) || + !Uri.TryCreate(DownloadUrl, UriKind.Absolute, out var url) || + url.Scheme != Uri.UriSchemeHttps || url.UserInfo.Length != 0 || + url.Fragment.Length != 0 || !url.AbsolutePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || + !Regex.IsMatch(Sha256 ?? "", "^[a-f0-9]{64}$") || Size <= 0) { + throw new InvalidDataException("Invalid DLL registry metadata."); + } + } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public sealed record PluginInfo( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("authors")] IReadOnlyList Authors, + [property: JsonPropertyName("plugin_dependencies"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? PluginDependencies = null, + [property: JsonPropertyName("icon_url"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? IconUrl = null, + [property: JsonPropertyName("image_urls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? ImageUrls = null, + [property: JsonPropertyName("mod_dependencies"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? ModDependencies = null, + [property: JsonPropertyName("repo_url"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? RepoUrl = null, + [property: JsonPropertyName("target")] string Target = "shared", + [property: JsonPropertyName("$schema"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Schema = null) { + [JsonIgnore] + public IReadOnlyList RequiredMods => ModDependencies ?? []; + [JsonIgnore] + public IReadOnlyList RequiredPlugins => PluginDependencies ?? []; + public void Validate() { + if (Target is not ("client" or "server" or "shared")) + throw new InvalidDataException("Plugin target must be client, server or shared."); + if (string.IsNullOrWhiteSpace(Name) || string.IsNullOrWhiteSpace(Description) || Authors == null || + Authors.Count == 0 || Authors.Any(string.IsNullOrWhiteSpace)) + throw new InvalidDataException("Invalid plugin.json metadata."); + foreach (var imageUrl in (ImageUrls ?? []).Concat(IconUrl == null ? [] : new[] { IconUrl })) { + if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var image) || image.Scheme != Uri.UriSchemeHttps || image.UserInfo.Length != 0) + throw new InvalidDataException("Plugin image/icon URLs must use HTTPS without credentials."); + } + var repos = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (kind, dependency) in RequiredMods.Select(dependency => ("mod", dependency)) + .Concat(RequiredPlugins.Select(dependency => ("plugin", dependency)))) { + var repository = kind == "mod" ? @"(?!\.{1,2}$)[A-Za-z0-9_.-]+" : "[A-Za-z0-9_-]+"; + if (dependency == null || !Uri.TryCreate(dependency.RepoUrl, UriKind.Absolute, out var url) || + url.Scheme != Uri.UriSchemeHttps || url.Host != "github.com" || url.UserInfo.Length != 0 || !url.IsDefaultPort || + !Regex.IsMatch(url.AbsolutePath, "^/[A-Za-z0-9_-]+/" + repository + "$") || url.Query.Length != 0 || url.Fragment.Length != 0 || + !repos.Add(kind + ":" + dependency.RepoUrl)) + throw new InvalidDataException("Invalid plugin dependency repository."); + MatchesVersion("1.0.0", dependency.Version); + } + } + + 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..]; + if (requirement.StartsWith('^')) { + var lower = SemVersion.Parse(requirement[1..], SemVersionStyles.Strict); + var upper = lower.Major != 0 ? new SemVersion(checked(lower.Major + 1), 0, 0) + : lower.Minor != 0 ? new SemVersion(0, checked(lower.Minor + 1), 0) + : new SemVersion(0, 0, checked(lower.Patch + 1)); + return candidate.ComparePrecedenceTo(lower) >= 0 && candidate.ComparePrecedenceTo(upper) < 0; + } + var matches = true; + foreach (var part in requirement.Split(',')) { + var match = Regex.Match(part.Trim(), "^(>=|<=|==|!=|>|<|=)?(.+)$"); + if (!match.Success) throw new InvalidDataException("Invalid dependency version."); + var target = SemVersion.Parse(match.Groups[2].Value, SemVersionStyles.Strict); + var comparison = candidate.ComparePrecedenceTo(target); + matches &= match.Groups[1].Value switch { + ">=" => comparison >= 0, + "<=" => comparison <= 0, + ">" => comparison > 0, + "<" => comparison < 0, + "!=" => comparison != 0, + "" or "=" or "==" => comparison == 0, + _ => throw new InvalidDataException("Invalid dependency version operator.") + }; + } + return matches; + } + } + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public sealed record PluginPackage( + [property: JsonPropertyName("latest_release_info"), JsonRequired] PluginInfo LatestReleaseInfo, + [property: JsonPropertyName("releases"), JsonRequired] IReadOnlyList Releases); + + [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] + public sealed record PluginRelease( + [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] PluginInfo? Info, + [property: JsonPropertyName("release_notes_markdown"), JsonRequired] string? ReleaseNotesMarkdown) { + public DllRelease ToDllRelease(string organization, string name) => new(organization, name, + SemVersion.Parse(Tag, SemVersionStyles.AllowV).ToString(), DownloadUrl, Hash, Size, Info, + Tag, ReleaseDate, ReleaseNotesMarkdown, DllFileName); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/IModManager.cs b/UnchainedLauncher.Core/Services/Mods/IModManager.cs index b3f7e29a..0ccd9d8a 100644 --- a/UnchainedLauncher.Core/Services/Mods/IModManager.cs +++ b/UnchainedLauncher.Core/Services/Mods/IModManager.cs @@ -389,7 +389,7 @@ private static ImmutableHashSet AggregateUniqueDependencies(this IModMa var newDependencies = modManager.GetLatestRelease(coordinates) .ToList() - .Bind(release => release.Info.Dependencies) + .Bind(release => release.Info.EffectiveModDependencies) .Map(ModIdentifier.FromDependency) .Bind(id => modManager.GetLatestRelease(id).ToList()) // Get the latest release for each dependency (we're ignoring dependency version range requirements currently) .Filter(dep => !seenDependencies.Contains(dep)) // Filter out any dependency we've already seen diff --git a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs index 79ca1dbb..e4744ea6 100644 --- a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs +++ b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs @@ -55,10 +55,10 @@ public static ModManager ToClassType(ModManagerMetadata metadata, IModRegistry r }; private static bool IsUsableCache(Mod mod) => - mod?.LatestReleaseInfo is { RepoUrl: not null, Authors: not null, Dependencies: not null, ImageUrls: not null } + mod?.LatestReleaseInfo is { RepoUrl: not null, Authors: not null, ImageUrls: not null } && mod.Releases != null && mod.Releases.All(r => - r?.Info is { RepoUrl: not null, Authors: not null, Dependencies: not null, ImageUrls: not null } + r?.Info is { RepoUrl: not null, Authors: not null, ImageUrls: not null } && r.Manifest is { Maps: not null, Blueprints: not null, Markers: not null, Replacements: not null, Arbitrary: not null }); private static PakDir CreatePakDir(PakDirMetadata? metadata) => diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index 1645e0ff..2492daf3 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -1,419 +1,433 @@ -using LanguageExt; -using LanguageExt.Common; -using log4net; -using System.Text; -using System.Text.RegularExpressions; -using UnchainedLauncher.Core.Extensions; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Utilities; - -namespace UnchainedLauncher.Core.Services.Mods { - using static LanguageExt.Prelude; - - public class PakDir : IPakDir { - public string DirPath { get; } - - private const string BasePakFileName = "pakchunk0-WindowsNoEditor.pak"; - private const string BaseSigFileName = "pakchunk0-WindowsNoEditor.sig"; - - private static readonly ILog Logger = LogManager.GetLogger(nameof(PakDir)); - - public IEnumerable ManagedPaks => _managedPaks; - private readonly List _managedPaks; - - public PakDir(string dirPath, IEnumerable managedPaks) { - DirPath = dirPath; - _managedPaks = managedPaks.ToList(); - SynchronizeWithDir(); - } - - private IEnumerable GetSigFiles() { - return Directory.EnumerateFiles(DirPath, "*.sig"); - } - - private IEnumerable GetModPakFiles() { - return Directory.EnumerateFiles(DirPath, "*.pak") - .Filter(f => !f.EndsWith(BasePakFileName)); - } - - /// - /// Helper for deleting files using try - /// - /// file name to delete - /// - private static Either _deleteFile(string path) { - return PrimitiveExtensions - .TryVoid(() => File.Delete(path)) - .Invoke() - .Match>( - _ => Right(Unit.Default), - e => Error.New($"Failed to delete file: '{path}'", (Exception)e) - ); - } - - /// - /// Helper for copying files - /// - /// - /// - /// - private static Either _copyFile(string path, string destPath) { - return PrimitiveExtensions - .TryVoid(() => File.Copy(path, destPath)) - .Invoke() - .Match>( - _ => Right(Unit.Default), - e => Error.New($"Failed to copy file: '{path}' -> '{destPath}'", (Exception)e) - ); - } - - public Either Uninstall(ModIdentifier coords) => - GetManagedPakFile(coords) - .Match( - Some: pak => Right(pak) - .BindTap(x => _unsignFile(PakNameToPakPath(x.PakFileName))) - .Bind(x => _deleteFile(PakNameToPakPath(x.PakFileName))), - None: () => { - Logger.Debug($"No pak file found for {coords}. Ignoring Uninstall request"); - return Right(Unit.Default); - } - ); - - private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter, Option> progress, string suggestedFileName) { - var unManagedPaks = GetUnmanagedPaks().ToHashSet(); - var actualName = Path.GetFileNameWithoutExtension(suggestedFileName); - var extension = Path.GetExtension(suggestedFileName); - var fullActualName = actualName + extension; - while (_managedPaks.Exists(x => PakNameToPakPath(x.PakFileName) == fullActualName) || unManagedPaks.Contains(PakNameToPakPath(fullActualName))) { - actualName = Successors.TextualSuccessor(actualName); - fullActualName = actualName + extension; - } - - 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) - .Map(_ => fullActualName) - ); - } - - public async IAsyncEnumerable> InstallModSet( - IEnumerable installs, - Option progress) { - - // Input is trusted to be pre-sorted by dependency order - var installsList = installs.ToList(); - var installCoordinatesSet = installsList.Select(i => i.Coordinates).ToHashSet(); - - // Collect all pak file names, adding org prefix for duplicate module names - var moduleNameCounts = installsList - .GroupBy(i => i.Coordinates.ModuleName) - .Where(g => g.Count() > 1) - .Select(g => g.Key) - .ToHashSet(); - - var pakFileNames = installsList - .Select(install => { - var moduleName = install.Coordinates.ModuleName; - var needsOrgPrefix = moduleNameCounts.Contains(moduleName); - var baseName = needsOrgPrefix - ? $"{install.Coordinates.Org}_{moduleName}" - : moduleName; - return baseName + ".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 => installsList.Exists(s => s.Coordinates == p.Coordinates)) - .ToDictionary(p => p.Coordinates, p => p); - - // Categorize each install: Skip (already correct), Move (relocate), or Download (new) - var installActions = installsList - .Zip(sortedPakNames, (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( - Some: pak => 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) - ); - }) - .ToList(); - - // Categorize managed paks not in install set as Remove actions - installActions.AddRange( - _managedPaks - .Filter(p => !installCoordinatesSet.Any(c => c.Matches(p.Coordinates))) - .Select(p => new InstallAction.Remove(p)) - .ToList() - ); - - var removeActions = installActions.OfType().ToList(); - var skipActions = installActions.OfType().ToList(); - var moveActions = installActions.OfType().ToList(); - var downloadActions = installActions.OfType().ToList(); - - var reorderCount = skipActions.Count + moveActions.Count; - - // Set up all progress tracking upfront so users see the full scope of work - var removeProgress = removeActions.Any() - ? progress.Map(aggregateProgress => { - var prog = new MemoryProgress($"Removing {removeActions.Count} paks"); - aggregateProgress.AlsoTrack(prog); - return prog; - }) - : Option.None; - - var reorderProgress = reorderCount > 0 - ? progress.Map(aggregateProgress => { - var prog = new MemoryProgress($"Reordering {reorderCount} paks"); - aggregateProgress.AlsoTrack(prog); - return prog; - }) - : Option.None; - - var downloadProgressMap = downloadActions - .Select(d => { - var prog = progress.Map(aggregateProgress => { - var mp = new MemoryProgress($"Installing {d.Install.Coordinates.ModuleName}"); - aggregateProgress.AlsoTrack(mp); - return mp; - }); - return (Download: d, Progress: prog); - }) - .ToDictionary(x => x.Download, x => x.Progress); - - // Process removes - for (var i = 0; i < removeActions.Count; i++) { - ProcessRemove(removeActions[i]); - removeProgress.IfSome(p => p.Report(100d * (i + 1) / removeActions.Count)); - } - - // Process skips and moves together as "reordering" - var reorderIndex = 0; - foreach (var result in skipActions.Select(ProcessSkip)) { - yield return result; - reorderIndex++; - reorderProgress.IfSome(p => p.Report(100d * reorderIndex / reorderCount)); - } - - foreach (var result in moveActions.Select(ProcessMove)) { - yield return result; - reorderIndex++; - reorderProgress.IfSome(p => p.Report(100d * reorderIndex / reorderCount)); - } - - // Process downloads for mods not yet present - foreach (var download in downloadActions) { - var taskProgress = downloadProgressMap[download]; - yield return await ProcessDownload(download, taskProgress); - } - } - - private abstract record InstallAction { - public record Skip(ModInstallRequest Install, int Index, ManagedPak Existing) : InstallAction; - public record Move(ModInstallRequest Install, int Index, ManagedPak Existing, string TargetPakName) : InstallAction; - public record Download(ModInstallRequest Install, int Index, string FinalPakName) : InstallAction; - public record Remove(ManagedPak Existing) : InstallAction; - } - - private Either ProcessSkip(InstallAction.Skip skip) { - var desired = new ManagedPak(skip.Install.Coordinates, skip.Existing.PakFileName, skip.Index); - - // Update priority in _managedPaks if changed - if (skip.Existing.Priority != skip.Index) { - var idx = _managedPaks.FindIndex(p => p.Coordinates.Matches(skip.Install.Coordinates)); - if (idx >= 0) _managedPaks[idx] = desired; - } - - Logger.Debug($"Skipping {skip.Install.Coordinates.ModuleName} - already at {skip.Existing.PakFileName}"); - return Right(desired); - } - - private Either ProcessMove(InstallAction.Move move) { - var sourcePath = PakNameToPakPath(move.Existing.PakFileName); - var targetPath = PakNameToPakPath(move.TargetPakName); - - return _moveManagedPak(sourcePath, targetPath) - .Map(_ => { - var managedPak = new ManagedPak(move.Install.Coordinates, move.TargetPakName, move.Index); - _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); - _managedPaks.Add(managedPak); - Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {move.TargetPakName}"); - return managedPak; - }); - } - - private async Task> ProcessDownload(InstallAction.Download download, Option taskProgress) { - return (await _writePak(download.Install.Writer, taskProgress.Map(p => (IProgress)p), download.FinalPakName)) - .Map(pakFileName => { - var managedPak = new ManagedPak(download.Install.Coordinates, pakFileName, download.Index); - _managedPaks.Add(managedPak); - _signFile(PakNameToPakPath(managedPak.PakFileName)) - .IfLeft(e => Logger.Error($"Failed to sign {managedPak.PakFileName}", e)); - return managedPak; - }); - } - - private void 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))); - uninstallResult.IfLeft(e => Logger.Error($"Failed to remove pak {remove.Existing.PakFileName}: {e}")); - } - - /// - /// Moves a managed pak (and its .sig if present) from source to destination. - /// - private static Either _moveManagedPak(string sourcePakPath, string destPakPath) { - 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)); - } - - private static Either _moveFile(string source, string dest) { - return PrimitiveExtensions - .TryVoid(() => File.Move(source, dest)) - .Invoke() - .Match>( - _ => Right(Unit.Default), - e => Error.New($"Failed to move file: '{source}' -> '{dest}'", (Exception)e) - ); - } - - /// - /// Does nothing if the sign file already exists - /// - /// - /// - private Either _signFile(string path) { - var signedName = Path.ChangeExtension(path, ".sig"); - if (File.Exists(signedName)) { - return Right(Unit.Default); - } - var sigPath = Path.Join(DirPath, BaseSigFileName); - return (File.Exists(sigPath) - ? Either.Right(sigPath) - : Error.New($"Default sig file '{sigPath}' not found")) - .Bind(defaultPath => _copyFile(defaultPath, signedName)); - } - - private static Either _unsignFile(string path) { - var signedName = Path.ChangeExtension(path, ".sig"); - return _deleteFile(signedName); - } - - /// - /// Delete any ReleaseMap entries that don't have a corresponding file on disk anymore. - /// NOTE: this should only do anything when the *user* messed something up - /// - private void SynchronizeWithDir() { - var missing = _managedPaks - .Filter(p => !File.Exists(PakNameToPakPath(p.PakFileName))) - .ToHashSet(); - - if (missing.Count == 0) return; - - Logger.LogListWarn( - "The following files were missing from the pak dir when they were expected to exist:", - missing - ); - - _managedPaks.RemoveAll(missing.Contains); - } - - private Option GetManagedPakFile(ModIdentifier coords) { - return _managedPaks - .Filter(p => p.Coordinates.Matches(coords)) - .ToOption(); - } - - public Option GetManagedPakFilePath(ReleaseCoordinates coords) { - return GetManagedPakFile(coords) - .Map(pak => PakNameToPakPath(pak.PakFileName)); - } - - private string PakNameToPakPath(string fileName) { - return Path.Join(DirPath, fileName); - } - - private IEnumerable GetUnmanagedPaks() { - return GetModPakFiles() - .Filter(p => !_managedPaks.Exists(pak => pak.PakFileName.EndsWith(Path.GetFileName(p)))); - } - - public Either, Unit> SignAll() { - return GetModPakFiles() - .Map(_signFile) - .BindLefts(); - } - - public Either, Unit> UnSignAll() { - return GetModPakFiles() - .Map(_unsignFile) - .BindLefts(); - } - - public Either, Unit> Reset() { - return Directory.EnumerateFiles(DirPath) - .Filter(p => !(p.EndsWith(BasePakFileName) || p.EndsWith(BaseSigFileName))) - .Map(_deleteFile) - .BindLefts(); - } - - public Either, Unit> DeleteOrphanedSigs() { - return GetSigFiles() - .Filter(p => !File.Exists(Path.ChangeExtension(p, ".pak"))) - .Map(_deleteFile) - .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) { - const string forcedSortDivider = "__-__"; - 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}", "")}" - ); - } - } +using LanguageExt; +using LanguageExt.Common; +using log4net; +using System.Text; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.Extensions; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Mods { + using static LanguageExt.Prelude; + + public class PakDir : IPakDir { + public string DirPath { get; } + + 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) { + DirPath = dirPath; + _managedPaks = managedPaks.ToList(); + SynchronizeWithDir(); + } + + private IEnumerable GetSigFiles() { + return Directory.EnumerateFiles(DirPath, "*.sig"); + } + + private IEnumerable GetModPakFiles() { + return Directory.EnumerateFiles(DirPath, "*.pak") + .Filter(f => !f.EndsWith(BasePakFileName)); + } + + /// + /// Helper for deleting files using try + /// + /// file name to delete + /// + private static Either _deleteFile(string path) { + return PrimitiveExtensions + .TryVoid(() => File.Delete(path)) + .Invoke() + .Match>( + _ => Right(Unit.Default), + e => Error.New($"Failed to delete file: '{path}'", (Exception)e) + ); + } + + /// + /// Helper for copying files + /// + /// + /// + /// + private static Either _copyFile(string path, string destPath) { + return PrimitiveExtensions + .TryVoid(() => File.Copy(path, destPath)) + .Invoke() + .Match>( + _ => Right(Unit.Default), + e => Error.New($"Failed to copy file: '{path}' -> '{destPath}'", (Exception)e) + ); + } + + public Either Uninstall(ModIdentifier coords) => + GetManagedPakFile(coords) + .Match( + Some: pak => Right(pak) + .Bind(x => _unsignFile(PakNameToPakPath(x.PakFileName)) + .Bind(_ => _deleteFile(PakNameToPakPath(x.PakFileName)))), + None: () => { + Logger.Debug($"No pak file found for {coords}. Ignoring Uninstall request"); + return Right(Unit.Default); + } + ); + + private string AvailablePakName(string suggestedFileName) { + var unManagedPaks = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); + var actualName = Path.GetFileNameWithoutExtension(suggestedFileName); + var extension = Path.GetExtension(suggestedFileName); + var fullActualName = actualName + extension; + while (_managedPaks.Exists(x => x.PakFileName.Equals(fullActualName, StringComparison.OrdinalIgnoreCase)) + || unManagedPaks.Contains(PakNameToPakPath(fullActualName))) { + actualName = Successors.TextualSuccessor(actualName); + fullActualName = actualName + extension; + } + return fullActualName; + } + + private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter, Option> progress, string suggestedFileName) { + var fullActualName = AvailablePakName(suggestedFileName); + 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) + .Map(_ => fullActualName) + ); + } + + public async IAsyncEnumerable> InstallModSet( + IEnumerable installs, + Option progress) { + + // Input is trusted to be pre-sorted by dependency order + var installsList = installs.ToList(); + var installCoordinatesSet = installsList.Select(i => i.Coordinates).ToHashSet(); + + // Collect all pak file names, adding org prefix for duplicate module names + var moduleNameCounts = installsList + .GroupBy(i => i.Coordinates.ModuleName) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(); + + var pakFileNames = installsList + .Select(install => { + var moduleName = install.Coordinates.ModuleName; + var needsOrgPrefix = moduleNameCounts.Contains(moduleName); + var baseName = needsOrgPrefix + ? $"{install.Coordinates.Org}_{moduleName}" + : moduleName; + return baseName + ".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); + var unmanagedPaths = GetUnmanagedPaks().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)) + .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. + 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)) + ? 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) + ); + }) + .ToList(); + + // Categorize managed paks not in install set as Remove actions + installActions.AddRange( + _managedPaks + .Filter(p => !installCoordinatesSet.Any(c => c.Matches(p.Coordinates))) + .Select(p => new InstallAction.Remove(p)) + .ToList() + ); + + var removeActions = installActions.OfType().ToList(); + var skipActions = installActions.OfType().ToList(); + var moveActions = installActions.OfType().ToList(); + var downloadActions = installActions.OfType().ToList(); + + var reorderCount = skipActions.Count + moveActions.Count; + + // Set up all progress tracking upfront so users see the full scope of work + var removeProgress = removeActions.Any() + ? progress.Map(aggregateProgress => { + var prog = new MemoryProgress($"Removing {removeActions.Count} paks"); + aggregateProgress.AlsoTrack(prog); + return prog; + }) + : Option.None; + + var reorderProgress = reorderCount > 0 + ? progress.Map(aggregateProgress => { + var prog = new MemoryProgress($"Reordering {reorderCount} paks"); + aggregateProgress.AlsoTrack(prog); + return prog; + }) + : Option.None; + + var downloadProgressMap = downloadActions + .Select(d => { + var prog = progress.Map(aggregateProgress => { + var mp = new MemoryProgress($"Installing {d.Install.Coordinates.ModuleName}"); + aggregateProgress.AlsoTrack(mp); + return mp; + }); + return (Download: d, Progress: prog); + }) + .ToDictionary(x => x.Download, x => x.Progress); + + // 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); + removeProgress.IfSome(p => p.Report(100d * (i + 1) / removeActions.Count)); + } + + // Process skips and moves together as "reordering" + var reorderIndex = 0; + foreach (var result in skipActions.Select(ProcessSkip)) { + yield return result; + reorderIndex++; + reorderProgress.IfSome(p => p.Report(100d * reorderIndex / reorderCount)); + } + + foreach (var result in moveActions.Select(ProcessMove)) { + yield return result; + reorderIndex++; + reorderProgress.IfSome(p => p.Report(100d * reorderIndex / reorderCount)); + } + + // Process downloads for mods not yet present + foreach (var download in downloadActions) { + var taskProgress = downloadProgressMap[download]; + yield return await ProcessDownload(download, taskProgress); + } + } + + private abstract record InstallAction { + public record Skip(ModInstallRequest Install, int Index, ManagedPak Existing) : InstallAction; + public record Move(ModInstallRequest Install, int Index, ManagedPak Existing, string TargetPakName) : InstallAction; + public record Download(ModInstallRequest Install, int Index, string FinalPakName) : InstallAction; + public record Remove(ManagedPak Existing) : InstallAction; + } + + private Either ProcessSkip(InstallAction.Skip skip) { + var desired = new ManagedPak(skip.Install.Coordinates, skip.Existing.PakFileName, skip.Index); + + // Update priority in _managedPaks if changed + if (skip.Existing.Priority != skip.Index) { + var idx = _managedPaks.FindIndex(p => p.Coordinates.Matches(skip.Install.Coordinates)); + if (idx >= 0) _managedPaks[idx] = desired; + } + + Logger.Debug($"Skipping {skip.Install.Coordinates.ModuleName} - already at {skip.Existing.PakFileName}"); + return Right(desired); + } + + private Either ProcessMove(InstallAction.Move move) { + var sourcePath = PakNameToPakPath(move.Existing.PakFileName); + 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; + }); + } + + private async Task> ProcessDownload(InstallAction.Download download, Option taskProgress) { + return (await _writePak(download.Install.Writer, taskProgress.Map(p => (IProgress)p), download.FinalPakName)) + .Map(pakFileName => { + var managedPak = new ManagedPak(download.Install.Coordinates, pakFileName, download.Index); + _managedPaks.Add(managedPak); + _signFile(PakNameToPakPath(managedPak.PakFileName)) + .IfLeft(e => Logger.Error($"Failed to sign {managedPak.PakFileName}", e)); + return managedPak; + }); + } + + 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))); + uninstallResult.IfLeft(e => Logger.Error($"Failed to remove pak {remove.Existing.PakFileName}: {e}")); + return uninstallResult; + } + + /// + /// Moves a managed pak (and its .sig if present) from source to destination. + /// + private static Either _moveManagedPak(string sourcePakPath, string destPakPath) { + 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)); + } + + private static Either _moveFile(string source, string dest) { + return PrimitiveExtensions + .TryVoid(() => File.Move(source, dest)) + .Invoke() + .Match>( + _ => Right(Unit.Default), + e => Error.New($"Failed to move file: '{source}' -> '{dest}'", (Exception)e) + ); + } + + /// + /// Does nothing if the sign file already exists + /// + /// + /// + private Either _signFile(string path) { + var signedName = Path.ChangeExtension(path, ".sig"); + if (File.Exists(signedName)) { + return Right(Unit.Default); + } + var sigPath = Path.Join(DirPath, BaseSigFileName); + return (File.Exists(sigPath) + ? Either.Right(sigPath) + : Error.New($"Default sig file '{sigPath}' not found")) + .Bind(defaultPath => _copyFile(defaultPath, signedName)); + } + + private static Either _unsignFile(string path) { + var signedName = Path.ChangeExtension(path, ".sig"); + return _deleteFile(signedName); + } + + /// + /// Delete any ReleaseMap entries that don't have a corresponding file on disk anymore. + /// NOTE: this should only do anything when the *user* messed something up + /// + private void SynchronizeWithDir() { + var missing = _managedPaks + .Filter(p => !File.Exists(PakNameToPakPath(p.PakFileName))) + .ToHashSet(); + + if (missing.Count == 0) return; + + Logger.LogListWarn( + "The following files were missing from the pak dir when they were expected to exist:", + missing + ); + + _managedPaks.RemoveAll(missing.Contains); + } + + private Option GetManagedPakFile(ModIdentifier coords) { + return _managedPaks + .Filter(p => p.Coordinates.Matches(coords)) + .ToOption(); + } + + public Option GetManagedPakFilePath(ReleaseCoordinates coords) { + return GetManagedPakFile(coords) + .Map(pak => PakNameToPakPath(pak.PakFileName)); + } + + private string PakNameToPakPath(string fileName) { + return Path.Join(DirPath, fileName); + } + + private IEnumerable GetUnmanagedPaks() { + return GetModPakFiles() + .Filter(p => !_managedPaks.Exists(pak => pak.PakFileName.Equals(Path.GetFileName(p), StringComparison.OrdinalIgnoreCase))); + } + + public Either, Unit> SignAll() { + return GetModPakFiles() + .Map(_signFile) + .BindLefts(); + } + + public Either, Unit> UnSignAll() { + return GetModPakFiles() + .Map(_unsignFile) + .BindLefts(); + } + + public Either, Unit> Reset() { + return Directory.EnumerateFiles(DirPath) + .Filter(p => !(p.EndsWith(BasePakFileName) || p.EndsWith(BaseSigFileName))) + .Map(_deleteFile) + .BindLefts(); + } + + public Either, Unit> DeleteOrphanedSigs() { + return GetSigFiles() + .Filter(p => !File.Exists(Path.ChangeExtension(p, ".pak"))) + .Map(_deleteFile) + .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/Registry/GithubModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs index a501f518..510c9715 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs @@ -1,69 +1,90 @@ -using LanguageExt; -using LanguageExt.Common; -using LanguageExt.SomeHelp; -using log4net; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Utilities; -using static LanguageExt.Prelude; - -namespace UnchainedLauncher.Core.Services.Mods.Registry { - public class GithubModRegistry : JsonRegistry, IModRegistry { - private static new readonly ILog Logger = LogManager.GetLogger(typeof(GithubModRegistry)); - - public override string Name => $"Github mod registry at {Organization}/{RepoName}"; - public string Organization { get; set; } - public string RepoName { get; set; } - public string PackageDBBaseUrl => $"https://raw.githubusercontent.com/{Organization}/{RepoName}/refs/heads/main/package_db"; - public string PackageDBPackageListUrl => $"{PackageDBBaseUrl}/mod_list_index.txt"; - - public GithubModRegistry(string organization, string repoName) { - Organization = organization; - RepoName = repoName; - } - - public override Task GetAllMods() { - return Prelude - .TryAsync(HttpHelpers.GetStringContentsAsync(PackageDBPackageListUrl).Task) - .Select(RegistryUtils.ParseLineSeparatedPackageList) - .Select(packages => packages.Select(GetMod)) - .Select(results => results.Partition()) - .ToEither() - .MapLeft(e => RegistryMetadataException.PackageListRetrieval($"Failed to retrieve package list from {PackageDBPackageListUrl}", e)) - .Match( - t => new GetAllModsResult(t.Lefts, t.Rights), // return the result if successful - e => new GetAllModsResult(e.ToSome().ToSeq(), Prelude.Seq()) // return an error if an error ocurred that prevented anything from being returned - ); - } - - protected override EitherAsync GetModMetadataString(ModIdentifier modIdentifier) { - return Prelude - .TryAsync(HttpHelpers.GetStringContentsAsync($"{PackageDBBaseUrl}/packages/{modIdentifier.Org}/{modIdentifier.ModuleName}.json").Task) - .ToEither() - .MapLeft(e => RegistryMetadataException.NotFound(modIdentifier, e)); - } - - public override EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) => - GetMod(coordinates) - .Map(releaseMetadata => releaseMetadata.Releases.Find(x => x.Tag == coordinates.Version)) - .Bind(maybeRelease => Optional(maybeRelease).ToEitherAsync(() => RegistryMetadataException.NotFound(coordinates, None))) - .MapLeft(e => new ModPakStreamAcquisitionFailure(coordinates, e)) - .Bind(GetGithubPakStream) - .Map(sizedStream => new FileWriter(outputLocation, sizedStream.Stream, sizedStream.Size)); - - private EitherAsync GetGithubPakStream(Release target) { - var url = GetGithubPakDownloadUrl(target); - var length = HttpHelpers.GetContentLengthAsync(url); - var streamDownloadTask = HttpHelpers.GetByteContentsAsync(url).Task; - return - Prelude.TryAsync(length) - .Bind(length => Prelude.TryAsync(streamDownloadTask) - .Map(stream => new SizedStream(stream, length)) - ) - .ToEither() - .MapLeft(e => new ModPakStreamAcquisitionFailure(ReleaseCoordinates.FromRelease(target), Error.New($"Failed to fetch pak from {url}.", e))); - } - - private string GetGithubPakDownloadUrl(Release release) => - $"https://github.com/{release.Info.Organization}/{release.Info.RepoName}/releases/download/{release.Tag}/{release.PakFileName}"; - } +using LanguageExt; +using LanguageExt.Common; +using LanguageExt.SomeHelp; +using log4net; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Mods.Registry { + public class GithubModRegistry : JsonRegistry, IModRegistry { + private static new readonly ILog Logger = LogManager.GetLogger(typeof(GithubModRegistry)); + + public override string Name => $"Github mod registry at {Organization}/{RepoName}"; + public string Organization { get; set; } + public string RepoName { get; set; } + private readonly string _revisionPath = "refs/heads/main"; + public string RegistryBaseUrl => $"https://raw.githubusercontent.com/{Organization}/{RepoName}/{_revisionPath}"; + public string PackageDBBaseUrl => $"{RegistryBaseUrl}/package_db"; + public string DllDatabaseUrl => $"{RegistryBaseUrl}/dll_db"; + public string PackageDBPackageListUrl => $"{PackageDBBaseUrl}/mod_list_index.txt"; + + public GithubModRegistry(string organization, string repoName) { + Organization = organization; + RepoName = repoName; + } + + public GithubModRegistry(string baseUrl) { + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) + || uri.Scheme != Uri.UriSchemeHttps || uri.Host != "raw.githubusercontent.com" + || !uri.IsDefaultPort || uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0) { + throw new InvalidDataException("Invalid GitHub registry base URL."); + } + + var parts = uri.AbsolutePath.Trim('/').Split('/'); + if (parts.Length < 3 || parts.Any(string.IsNullOrWhiteSpace) + || parts[^1] is "package_db" or "dll_db") { + throw new InvalidDataException("GitHub registry base URL must identify a repository revision, not a database."); + } + + Organization = parts[0]; + RepoName = parts[1]; + _revisionPath = string.Join('/', parts.Skip(2)); + } + + public override Task GetAllMods() { + return Prelude + .TryAsync(HttpHelpers.GetStringContentsAsync(PackageDBPackageListUrl).Task) + .Select(RegistryUtils.ParseLineSeparatedPackageList) + .Select(packages => packages.Select(GetMod)) + .Select(results => results.Partition()) + .ToEither() + .MapLeft(e => RegistryMetadataException.PackageListRetrieval($"Failed to retrieve package list from {PackageDBPackageListUrl}", e)) + .Match( + t => new GetAllModsResult(t.Lefts, t.Rights), // return the result if successful + e => new GetAllModsResult(e.ToSome().ToSeq(), Prelude.Seq()) // return an error if an error ocurred that prevented anything from being returned + ); + } + + protected override EitherAsync GetModMetadataString(ModIdentifier modIdentifier) { + return Prelude + .TryAsync(HttpHelpers.GetStringContentsAsync($"{PackageDBBaseUrl}/packages/{modIdentifier.Org}/{modIdentifier.ModuleName}.json").Task) + .ToEither() + .MapLeft(e => RegistryMetadataException.NotFound(modIdentifier, e)); + } + + public override EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) => + GetMod(coordinates) + .Map(releaseMetadata => releaseMetadata.Releases.Find(x => x.Tag == coordinates.Version)) + .Bind(maybeRelease => Optional(maybeRelease).ToEitherAsync(() => RegistryMetadataException.NotFound(coordinates, None))) + .MapLeft(e => new ModPakStreamAcquisitionFailure(coordinates, e)) + .Bind(GetGithubPakStream) + .Map(sizedStream => new FileWriter(outputLocation, sizedStream.Stream, sizedStream.Size)); + + private EitherAsync GetGithubPakStream(Release target) { + var url = GetGithubPakDownloadUrl(target); + var length = HttpHelpers.GetContentLengthAsync(url); + var streamDownloadTask = HttpHelpers.GetByteContentsAsync(url).Task; + return + Prelude.TryAsync(length) + .Bind(length => Prelude.TryAsync(streamDownloadTask) + .Map(stream => new SizedStream(stream, length)) + ) + .ToEither() + .MapLeft(e => new ModPakStreamAcquisitionFailure(ReleaseCoordinates.FromRelease(target), Error.New($"Failed to fetch pak from {url}.", e))); + } + + private string GetGithubPakDownloadUrl(Release release) => + $"https://github.com/{release.Info.Organization}/{release.Info.RepoName}/releases/download/{release.Tag}/{release.PakFileName}"; + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/ModRegistryCodec.cs b/UnchainedLauncher.Core/Services/Mods/Registry/ModRegistryCodec.cs index 5e0ed73f..a7f50f39 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/ModRegistryCodec.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/ModRegistryCodec.cs @@ -1,46 +1,52 @@ -using DiscriminatedUnions; -using UnchainedLauncher.Core.Utilities; - -namespace UnchainedLauncher.Core.Services.Mods.Registry { - public class ModRegistryCodec : DerivedJsonCodec { - public static ModRegistryCodec Instance { get; } = new ModRegistryCodec(); - - public ModRegistryCodec() : base(ToJsonType, ToClassType) { } - - public static IModRegistry ToClassType(ModRegistryMetadata metadata) => - metadata switch { - LocalModRegistryMetadata m => new LocalModRegistry(m.PakReleaseDir), - GithubModRegistryMetadata m => new GithubModRegistry(m.Org, m.RepoName), - AggregateModRegistryMetadata m => new AggregateModRegistry(m.Registries.Select(ToClassType).ToArray()), - - _ => throw new ArgumentOutOfRangeException(nameof(metadata), metadata, - $"Attempt to initialize unknown IModRegistry implementation: {metadata}") - }; - - public static ModRegistryMetadata ToJsonType(IModRegistry registry) => - registry switch { - LocalModRegistry d => new LocalModRegistryMetadata(d.RegistryPath), - GithubModRegistry d => new GithubModRegistryMetadata(d.Organization, d.RepoName), - AggregateModRegistry d => new AggregateModRegistryMetadata(d.ModRegistries.Select(ToJsonType).ToArray()), - - _ => throw new ArgumentOutOfRangeException(nameof(registry), registry, - $"Attempt to extract metadata from unknown IModRegistry implementation: {registry}") - }; - } - - [UnionTag(nameof(Kind))] - [UnionCase(typeof(LocalModRegistryMetadata), ModRegistryMetadataKind.LocalModRegistry)] - [UnionCase(typeof(GithubModRegistryMetadata), ModRegistryMetadataKind.GithubModRegistry)] - [UnionCase(typeof(AggregateModRegistryMetadata), ModRegistryMetadataKind.AggregateModRegistry)] - public abstract record ModRegistryMetadata(string Kind); - public record LocalModRegistryMetadata(string PakReleaseDir) : ModRegistryMetadata(ModRegistryMetadataKind.LocalModRegistry); - public record GithubModRegistryMetadata(string Org, string RepoName) : ModRegistryMetadata(ModRegistryMetadataKind.GithubModRegistry); - public record AggregateModRegistryMetadata(ModRegistryMetadata[] Registries) : ModRegistryMetadata(ModRegistryMetadataKind.AggregateModRegistry); - - internal static class ModRegistryMetadataKind { - public const string LocalModRegistry = "LocalModRegistry"; - public const string GithubModRegistry = "GithubModRegistry"; - public const string AggregateModRegistry = "AggregateModRegistry"; - } - +using DiscriminatedUnions; +using System.Text.Json.Serialization; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Mods.Registry { + public class ModRegistryCodec : DerivedJsonCodec { + public static ModRegistryCodec Instance { get; } = new ModRegistryCodec(); + + public ModRegistryCodec() : base(ToJsonType, ToClassType) { } + + public static IModRegistry ToClassType(ModRegistryMetadata metadata) => + metadata switch { + LocalModRegistryMetadata m => new LocalModRegistry(m.PakReleaseDir), + GithubModRegistryMetadata { BaseUrl: not null } m => new GithubModRegistry(m.BaseUrl), + GithubModRegistryMetadata { Org: not null, RepoName: not null } m => new GithubModRegistry(m.Org, m.RepoName), + AggregateModRegistryMetadata m => new AggregateModRegistry(m.Registries.Select(ToClassType).ToArray()), + + _ => throw new ArgumentOutOfRangeException(nameof(metadata), metadata, + $"Attempt to initialize unknown IModRegistry implementation: {metadata}") + }; + + public static ModRegistryMetadata ToJsonType(IModRegistry registry) => + registry switch { + LocalModRegistry d => new LocalModRegistryMetadata(d.RegistryPath), + GithubModRegistry d => new GithubModRegistryMetadata(BaseUrl: d.RegistryBaseUrl), + AggregateModRegistry d => new AggregateModRegistryMetadata(d.ModRegistries.Select(ToJsonType).ToArray()), + + _ => throw new ArgumentOutOfRangeException(nameof(registry), registry, + $"Attempt to extract metadata from unknown IModRegistry implementation: {registry}") + }; + } + + [UnionTag(nameof(Kind))] + [UnionCase(typeof(LocalModRegistryMetadata), ModRegistryMetadataKind.LocalModRegistry)] + [UnionCase(typeof(GithubModRegistryMetadata), ModRegistryMetadataKind.GithubModRegistry)] + [UnionCase(typeof(AggregateModRegistryMetadata), ModRegistryMetadataKind.AggregateModRegistry)] + public abstract record ModRegistryMetadata(string Kind); + public record LocalModRegistryMetadata(string PakReleaseDir) : ModRegistryMetadata(ModRegistryMetadataKind.LocalModRegistry); + public record GithubModRegistryMetadata( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Org = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? RepoName = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? BaseUrl = null) + : ModRegistryMetadata(ModRegistryMetadataKind.GithubModRegistry); + public record AggregateModRegistryMetadata(ModRegistryMetadata[] Registries) : ModRegistryMetadata(ModRegistryMetadataKind.AggregateModRegistry); + + internal static class ModRegistryMetadataKind { + public const string LocalModRegistry = "LocalModRegistry"; + public const string GithubModRegistry = "GithubModRegistry"; + public const string AggregateModRegistry = "AggregateModRegistry"; + } + } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2LaunchPreparer.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2LaunchPreparer.cs index 595a4591..7bef272e 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2LaunchPreparer.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2LaunchPreparer.cs @@ -1,98 +1,96 @@ -using LanguageExt; -using static LanguageExt.Prelude; - -namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { - - public static class Chivalry2LaunchPreparer { - public static IChivalry2LaunchPreparer Create(Func>> f) => - new FunctionalChivalry2LaunchPreparer(f); - - public static IChivalry2LaunchPreparer Create(Func> f) => - new FunctionalChivalry2LaunchPreparer(t => Task.FromResult(f(t))); - - public static IChivalry2LaunchPreparer IgnoreOptions(this IChivalry2LaunchPreparer u) { - return Create( - async opt => await u.PrepareLaunch(Unit.Default).Map(_ => opt) - ); - } - } - - /// - /// Performs tasks that sets up a proper launch - /// - public interface IChivalry2LaunchPreparer { - /// - /// Runs the preparations - /// - /// a Task containing None when preparations fail. Some(T) when successful. - public Task> PrepareLaunch(T options); - - public IChivalry2LaunchPreparer AndThen(IChivalry2LaunchPreparer otherLaunchPreparer) { - return Chivalry2LaunchPreparer.Create(opts => { - var result = - from modifiedOpts in OptionalAsync(this.PrepareLaunch(opts)) - from finalOpts in OptionalAsync(otherLaunchPreparer.PrepareLaunch(opts)) - select finalOpts; - - return result.Value; - }); - } - - /// - /// Allows for running some other IChivalry2LaunchPreparer of a different type - /// - /// - /// - /// - /// If the other launch preparer returns None then the resulting launch preparer will also return None, otherwise it'll return Some containing the original output. - public IChivalry2LaunchPreparer Sub(IChivalry2LaunchPreparer other, Func map) => - AndThen(t => other.PrepareLaunch(map(t)).Map(x => x.Map(_ => t))); - - /// - /// Allows for running some other IChivalry2LaunchPreparer of a different type - /// - /// - /// - /// - /// If the other launch preparer returns None then the resulting launch preparer will also return None, otherwise it'll return Some containing the original output. - public IChivalry2LaunchPreparer Sub(IChivalry2LaunchPreparer other) => - Sub(other, _ => Unit.Default); - - /// - /// Allows for running some other IChivalry2LaunchPreparer of a different type - /// - /// - /// - /// - /// - /// If the other launch preparer returns None, the resulting launch preparer will also return None. Otherwise it'll return Some containing the converted output. - public IChivalry2LaunchPreparer Sub( - IChivalry2LaunchPreparer other, - Func toT2, - Func toT) => - AndThen(other.InvariantMap(toT2, toT)); - - - public IChivalry2LaunchPreparer AndThen(Func>> f) => AndThen(Chivalry2LaunchPreparer.Create(f)); - public IChivalry2LaunchPreparer Bind(IChivalry2LaunchPreparer launchPreparer) => AndThen(launchPreparer); - - /// - /// - /// - /// - /// - /// - /// - public IChivalry2LaunchPreparer InvariantMap(Func toT, Func fromT) => - Chivalry2LaunchPreparer.Create(async t2 => (await PrepareLaunch(toT(t2))).Map(fromT)); - } - - public class FunctionalChivalry2LaunchPreparer : IChivalry2LaunchPreparer { - private readonly Func>> _f; - - public FunctionalChivalry2LaunchPreparer(Func>> func) { - _f = func; - } - public Task> PrepareLaunch(T options) => _f(options); - } +using LanguageExt; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { + + public static class Chivalry2LaunchPreparer { + public static IChivalry2LaunchPreparer Create(Func>> f) => + new FunctionalChivalry2LaunchPreparer(f); + + public static IChivalry2LaunchPreparer Create(Func> f) => + new FunctionalChivalry2LaunchPreparer(t => Task.FromResult(f(t))); + + public static IChivalry2LaunchPreparer IgnoreOptions(this IChivalry2LaunchPreparer u) { + return Create( + async opt => await u.PrepareLaunch(Unit.Default).Map(_ => opt) + ); + } + } + + /// + /// Performs tasks that sets up a proper launch + /// + public interface IChivalry2LaunchPreparer { + /// + /// Runs the preparations + /// + /// a Task containing None when preparations fail. Some(T) when successful. + public Task> PrepareLaunch(T options); + + public IChivalry2LaunchPreparer AndThen(IChivalry2LaunchPreparer otherLaunchPreparer) { + return Chivalry2LaunchPreparer.Create(async opts => { + var prepared = await PrepareLaunch(opts); + return await prepared.Match( + Some: otherLaunchPreparer.PrepareLaunch, + None: () => Task.FromResult(Option.None)); + }); + } + + /// + /// Allows for running some other IChivalry2LaunchPreparer of a different type + /// + /// + /// + /// + /// If the other launch preparer returns None then the resulting launch preparer will also return None, otherwise it'll return Some containing the original output. + public IChivalry2LaunchPreparer Sub(IChivalry2LaunchPreparer other, Func map) => + AndThen(t => other.PrepareLaunch(map(t)).Map(x => x.Map(_ => t))); + + /// + /// Allows for running some other IChivalry2LaunchPreparer of a different type + /// + /// + /// + /// + /// If the other launch preparer returns None then the resulting launch preparer will also return None, otherwise it'll return Some containing the original output. + public IChivalry2LaunchPreparer Sub(IChivalry2LaunchPreparer other) => + Sub(other, _ => Unit.Default); + + /// + /// Allows for running some other IChivalry2LaunchPreparer of a different type + /// + /// + /// + /// + /// + /// If the other launch preparer returns None, the resulting launch preparer will also return None. Otherwise it'll return Some containing the converted output. + public IChivalry2LaunchPreparer Sub( + IChivalry2LaunchPreparer other, + Func toT2, + Func toT) => + AndThen(other.InvariantMap(toT2, toT)); + + + public IChivalry2LaunchPreparer AndThen(Func>> f) => AndThen(Chivalry2LaunchPreparer.Create(f)); + public IChivalry2LaunchPreparer Bind(IChivalry2LaunchPreparer launchPreparer) => AndThen(launchPreparer); + + /// + /// + /// + /// + /// + /// + /// + public IChivalry2LaunchPreparer InvariantMap(Func toT, Func fromT) => + Chivalry2LaunchPreparer.Create(async t2 => (await PrepareLaunch(toT(t2))).Map(fromT)); + } + + public class FunctionalChivalry2LaunchPreparer : IChivalry2LaunchPreparer { + private readonly Func>> _f; + + public FunctionalChivalry2LaunchPreparer(Func>> func) { + _f = func; + } + public Task> PrepareLaunch(T options) => _f(options); + } } \ 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 61e9e6ec..50fa3f4a 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs @@ -1,110 +1,114 @@ -using LanguageExt; -using log4net; -using UnchainedLauncher.Core.Extensions; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Services.Mods; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Utilities; - -namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { - using static LanguageExt.Prelude; - - public class Chivalry2ModsInstaller : IChivalry2LaunchPreparer { - private readonly ILog _logger = LogManager.GetLogger(typeof(Chivalry2ModsInstaller)); - private readonly IUserDialogueSpawner _userDialogueSpawner; - private IModManager _modManager { get; } - - public Chivalry2ModsInstaller(IModManager modManager, IUserDialogueSpawner dialogueSpawner) { - _modManager = modManager; - _userDialogueSpawner = dialogueSpawner; - } - - private OptionAsync GetHashResult(EitherAsync> result) { - return result.TapLeft, object>(e => - _logger.Error($"Failed to hash file", e) - ) - .ToOption() - .Bind(o => o.Match(OptionAsync.Some, OptionAsync.None)); - } - - private OptionAsync GetLocalHash(ReleaseCoordinates coords) { - return _modManager.PakDir.GetManagedPakFilePath(coords) - .Match( - path => GetHashResult(FileHelpers.Sha512Async(path)), - () => OptionAsync.None); - } - - private Unit IfHashInvalid((Release, string, string) validationResult) { - var coords = ReleaseCoordinates.FromRelease(validationResult.Item1); - _logger.Warn($"Hash mismatch: actual hash '{validationResult.Item2}' does not " + - $"match release hash '{validationResult.Item3}' for '{coords}'."); - - var redownloadResponse = _userDialogueSpawner.DisplayYesNoMessage( - $"The hash for release {coords} is not correct. " + - $"This indicates that the file on disk does not match " + - $"what is approved by the mod registry. This could be due to " + - $"simple file corruption, but could also indicate that the version " + - $"you have is malicious.\n\n" + - $"Expected: {validationResult.Item3}\n" + - $"Actual: {validationResult.Item2}\n\n" + - $"Would you like to re-download the pak? (recommended)", "Refresh pak file?"); - - if (redownloadResponse == UserDialogueChoice.Yes) { - _modManager.PakDir.Uninstall(coords) - .IfLeft(e => - _logger.Error($"Failed to uninstall release '{coords}' with invalid hash", e)); - } - - return Unit.Default; - } - - private async Task ValidateHashes(IEnumerable releases) { - var hashValidationTasks = releases - .Map(m => - GetLocalHash(ReleaseCoordinates.FromRelease(m)) - .Map(h => (m, h, m.ReleaseHash)) - // select only those whose hashes do not match - .Filter(o => !o.Item2.Equals(o.Item3)) - .IfSome(IfHashInvalid) - ); - - await Task.WhenAll(hashValidationTasks); - } - - public async Task> PrepareLaunch(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()); - - // Validate hashes before installation - await ValidateHashes(releasesToInstall); - - // Install mods via ModManager - var progresses = new AccumulatedMemoryProgress(taskName: "Installing releases"); - var closeProgressWindow = _userDialogueSpawner.DisplayProgress(progresses); - - var installResults = await _modManager.InstallMods(progresses).ToListAsync(); - - var (errors, successes) = - installResults.Partition() - .MapFirst(x => x.ToList()) - .MapSecond(x => x.ToList()); - - successes.ForEach(succ => _logger.Debug($"Successfully installed {succ.Coordinates.ModuleName} version {succ.Coordinates.Version}")); - - if (errors.Count != 0) { - errors.ToList().ForEach(e => _logger.Error($"Failed to install releases: {e.Message}")); - var shouldContinue = _userDialogueSpawner.DisplayYesNoMessage( - $"There were errors while installing releases:\n " + - $"{errors.Aggregate((a, b) => $"{a}\n\n{b}")}\n" + - "Would you like to continue anyway?", "Errors encountered" - ); - if (shouldContinue == UserDialogueChoice.No) return None; - } - - closeProgressWindow(); - - return Some(options); - } - } +using LanguageExt; +using log4net; +using UnchainedLauncher.Core.Extensions; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { + using static LanguageExt.Prelude; + + public class Chivalry2ModsInstaller : IChivalry2LaunchPreparer { + private readonly ILog _logger = LogManager.GetLogger(typeof(Chivalry2ModsInstaller)); + private readonly IUserDialogueSpawner _userDialogueSpawner; + private IModManager _modManager { get; } + + public Chivalry2ModsInstaller(IModManager modManager, IUserDialogueSpawner dialogueSpawner) { + _modManager = modManager; + _userDialogueSpawner = dialogueSpawner; + } + + private OptionAsync GetHashResult(EitherAsync> result) { + return result.TapLeft, object>(e => + _logger.Error($"Failed to hash file", e) + ) + .ToOption() + .Bind(o => o.Match(OptionAsync.Some, OptionAsync.None)); + } + + private OptionAsync GetLocalHash(ReleaseCoordinates coords) { + return _modManager.PakDir.GetManagedPakFilePath(coords) + .Match( + path => GetHashResult(FileHelpers.Sha512Async(path)), + () => OptionAsync.None); + } + + private Unit IfHashInvalid((Release, string, string) validationResult) { + var coords = ReleaseCoordinates.FromRelease(validationResult.Item1); + _logger.Warn($"Hash mismatch: actual hash '{validationResult.Item2}' does not " + + $"match release hash '{validationResult.Item3}' for '{coords}'."); + + var redownloadResponse = _userDialogueSpawner.DisplayYesNoMessage( + $"The hash for release {coords} is not correct. " + + $"This indicates that the file on disk does not match " + + $"what is approved by the mod registry. This could be due to " + + $"simple file corruption, but could also indicate that the version " + + $"you have is malicious.\n\n" + + $"Expected: {validationResult.Item3}\n" + + $"Actual: {validationResult.Item2}\n\n" + + $"Would you like to re-download the pak? (recommended)", "Refresh pak file?"); + + if (redownloadResponse == UserDialogueChoice.Yes) { + _modManager.PakDir.Uninstall(coords) + .IfLeft(e => + _logger.Error($"Failed to uninstall release '{coords}' with invalid hash", e)); + } + + return Unit.Default; + } + + private async Task ValidateHashes(IEnumerable releases) { + var hashValidationTasks = releases + .Map(m => + GetLocalHash(ReleaseCoordinates.FromRelease(m)) + .Map(h => (m, h, m.ReleaseHash)) + // select only those whose hashes do not match + .Filter(o => !o.Item2.Equals(o.Item3)) + .IfSome(IfHashInvalid) + ); + + await Task.WhenAll(hashValidationTasks); + } + + public async Task> PrepareLaunch(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()); + + // Validate hashes before installation + await ValidateHashes(releasesToInstall); + + // Install mods via ModManager + var progresses = new AccumulatedMemoryProgress(taskName: "Installing releases"); + var closeProgressWindow = _userDialogueSpawner.DisplayProgress(progresses); + + List> installResults; + try { + installResults = await _modManager.InstallMods(progresses).ToListAsync(); + } + finally { + closeProgressWindow(); + } + + var (errors, successes) = + installResults.Partition() + .MapFirst(x => x.ToList()) + .MapSecond(x => x.ToList()); + + successes.ForEach(succ => _logger.Debug($"Successfully installed {succ.Coordinates.ModuleName} version {succ.Coordinates.Version}")); + + if (errors.Count != 0) { + errors.ForEach(e => _logger.Error($"Failed to install releases: {e.Message}")); + _userDialogueSpawner.DisplayMessage( + $"There were errors while installing releases:\n " + + $"{string.Join("\n\n", errors)}\n" + + "Launch cancelled. Resolve the pak setup errors before launching." + ); + return None; + } + + return Some(options); + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/DllLaunchPreparer.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/DllLaunchPreparer.cs new file mode 100644 index 00000000..29f996fa --- /dev/null +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/DllLaunchPreparer.cs @@ -0,0 +1,33 @@ +using LanguageExt; +using log4net; +using UnchainedLauncher.Core.Services.Dlls; + +namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { + public sealed class DllLaunchPreparer(DllManager manager, IUserDialogueSpawner dialogue) : IChivalry2LaunchPreparer { + public async Task> PrepareLaunch(LaunchOptions options) { + try { + var server = options.ServerLaunchOptions.IsSome; + if (options.CheckForDependencyUpdates) { + try { await manager.RefreshAsync(); } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { + LogManager.GetLogger(typeof(DllLaunchPreparer)).Warn("DLL refresh unavailable; retaining cached approvals.", ex); + } + foreach (var (current, latest) in manager.GetUpdateCandidates(server, options.AllowUnstablePluginReleases)) { + var update = new DependencyUpdate(latest.EffectiveFileName, current.Version, latest.Version, + latest.GithubReleaseUrl, latest.Info?.Description ?? "Native plugin"); + if (dialogue.DisplayUpdateMessage("Update plugin", $"Would you like to update {latest.Info?.Name ?? latest.Id}?", + "yes", "no", null, update) == UserDialogueChoice.Yes) + manager.SetEnabled(latest, true); + } + } + await manager.PrepareAsync(server: server, allowDownloads: options.CheckForDependencyUpdates); + return options; + } + catch (Exception ex) { + LogManager.GetLogger(typeof(DllLaunchPreparer)).Error("DLL validation failed", ex); + dialogue.DisplayMessage($"DLL validation failed: {ex.Message}"); + return LanguageExt.Prelude.None; + } + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs index ad9a4ec6..c1624b3f 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs @@ -88,14 +88,14 @@ public async Task> TryLaunch(LaunchOptio }, Right: proc => { Logger.Info("Successfully launched Chivalry 2 process."); - return InjectDlLs(proc); + return InjectDLLs(proc, server: updatedLaunchOpts.ServerLaunchOptions.IsSome); } ); } - private Either InjectDlLs(Process process) { + private Either InjectDLLs(Process process, bool server) { Logger.Info($"Injecting DLLs into {process.Id}"); - if (ProcessInjector.Inject(process)) return Right(process); + if (ProcessInjector.Inject(process, server)) return Right(process); process.Kill(); return Left(UnchainedLaunchFailure.InjectionFailed()); diff --git a/UnchainedLauncher.Core/Services/Processes/DllInjector.cs b/UnchainedLauncher.Core/Services/Processes/DllInjector.cs index 2cebad19..98d058be 100644 --- a/UnchainedLauncher.Core/Services/Processes/DllInjector.cs +++ b/UnchainedLauncher.Core/Services/Processes/DllInjector.cs @@ -3,6 +3,7 @@ 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 @@ -36,6 +37,12 @@ static extern IntPtr CreateRemoteThread(IntPtr hProcess, [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; @@ -50,30 +57,33 @@ static extern IntPtr CreateRemoteThread(IntPtr hProcess, const uint PageReadwrite = 4; - private string DllDir { get; } + private readonly DllManager _dllManager; - public DllInjector(string dllDir) { - DllDir = dllDir; + public DllInjector(DllManager dllManager) { + _dllManager = dllManager; } - public bool Inject(Process p) { + 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 { - var paths = - Directory.Exists(DllDir) - ? Directory.EnumerateFiles(DllDir, "*.dll", SearchOption.AllDirectories) - : Enumerable.Empty(); + files = _dllManager.OpenForInjection(server); + var paths = files.Select(file => file.Path).ToArray(); - if (paths.Length() == 0) { + 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)); + paths = paths.Select(p => Path.GetFullPath(p)).ToArray(); _logger.LogListInfo("Injecting DLLs: ", paths); - var procHandle = OpenProcess(ProcessCreateThread | + procHandle = OpenProcess(ProcessCreateThread | ProcessQueryInformation | ProcessVmOperation | ProcessVmWrite | ProcessVmRead, false, p.Id); if (procHandle == IntPtr.Zero) { @@ -81,17 +91,17 @@ public bool Inject(Process p) { } var maxLen = paths.Max(p => p.Length); - var allocSize = (uint)((maxLen + 1) * Marshal.SizeOf(typeof(char))); + var allocSize = checked((uint)((maxLen + 1) * sizeof(char))); - // searching for the address of LoadLibraryA and storing it in a pointer - var loadLibraryAddr = GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryA"); + // 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 - var allocMemAddress = VirtualAllocEx(procHandle, + allocMemAddress = VirtualAllocEx(procHandle, IntPtr.Zero, allocSize, MemCommit | MemReserve, PageReadwrite); @@ -101,25 +111,34 @@ public bool Inject(Process p) { foreach (var path in paths) { // writing the name of the dll there + var bytes = Encoding.Unicode.GetBytes(path + '\0'); var res = WriteProcessMemory(procHandle, allocMemAddress, - Encoding.Default.GetBytes(path + '\0'), - (uint)((path.Length + 1) * Marshal.SizeOf(typeof(char))), + bytes, (uint)bytes.Length, out var bytesWritten); - if (!res) { return false; } + if (!res || bytesWritten.ToUInt64() != (ulong)bytes.Length) { return false; } //inject var thread = CreateRemoteThread(procHandle, IntPtr.Zero, 0, loadLibraryAddr, allocMemAddress, 0, IntPtr.Zero); - var signalEvent = WaitForSingleObject(thread, unchecked((uint)-1)); + 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); } } - VirtualFreeEx(procHandle, allocMemAddress, allocSize, MemRelease); 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/IProcessInjector.cs b/UnchainedLauncher.Core/Services/Processes/IProcessInjector.cs index 83ac7d3a..97b8f963 100644 --- a/UnchainedLauncher.Core/Services/Processes/IProcessInjector.cs +++ b/UnchainedLauncher.Core/Services/Processes/IProcessInjector.cs @@ -8,6 +8,8 @@ namespace UnchainedLauncher.Core.Services.Processes { /// public interface IProcessInjector { public bool Inject(Process p); + // Injectors without side-specific plugins keep their existing behavior. + public bool Inject(Process p, bool server) => Inject(p); } diff --git a/UnchainedLauncher.Core/Utilities/FilePaths.cs b/UnchainedLauncher.Core/Utilities/FilePaths.cs index a5378ec5..d608d124 100644 --- a/UnchainedLauncher.Core/Utilities/FilePaths.cs +++ b/UnchainedLauncher.Core/Utilities/FilePaths.cs @@ -14,6 +14,7 @@ public static class FilePaths { public const string ServersTabConfigurationPath = $"{ModCachePath}\\servers_tab.json"; public const string RegistryConfigPath = $"{ModCachePath}\\registry_config.json"; 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 static string Chiv2ConfigPath(string? savedDirSuffix) { diff --git a/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs new file mode 100644 index 00000000..425301b8 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs @@ -0,0 +1,503 @@ +using System.Text.Json; +using UnchainedLauncher.Core.Services.Dlls; +using UnchainedLauncher.GUI.ViewModels; +using Xunit; + +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")); + Directory.CreateDirectory(directory); + try { + var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { Release("Plugin") }) }; + using var http = new HttpClient(handler); + var state = Path.Combine(directory, "state.json"); + File.WriteAllText(state, "{}"); + var manager = new DllManager(Path.Combine(directory, "Plugins"), state, http); + var vm = new DllManagerVM(manager); + await vm.Refresh(); + manager.SetEnabled(Assert.Single(manager.Releases), true); + handler.Json = JsonSerializer.Serialize(new[] { Release("Plugin", "2.0.0") }); + await vm.Refresh(); + var plugin = Assert.Single(vm.Plugins); + Assert.True(plugin.IsUnavailable); + Assert.Null(plugin.SelectedVersion); + Assert.Equal("2.0.0", Assert.Single(plugin.AvailableVersions).Version); + Assert.Equal("1.0.0", plugin.EnabledVersion); + plugin.EnableOrDisable(); + plugin = Assert.Single(vm.Plugins); + Assert.False(plugin.IsEnabled); + Assert.Empty(manager.EnabledSelections); + plugin.EnableOrDisable(); + Assert.Equal("2.0.0", Assert.Single(vm.Plugins).EnabledVersion); + Assert.True(manager.IsEnabled(Assert.Single(manager.Releases))); + } + finally { Directory.Delete(directory, true); } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [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")); + Directory.CreateDirectory(directory); + try { + var release = Release("RemovedPlugin"); + var handler = new MutablePackageHandler(release); + using var http = new HttpClient(handler); + var state = Path.Combine(directory, "state.json"); + File.WriteAllText(state, "{}"); + IReadOnlyList sources = [DllManager.DefaultDatabaseUrl]; + DllManager Create() => new(Path.Combine(directory, "Plugins"), state, http, registryUrls: () => sources); + var manager = Create(); + await manager.RefreshAsync(); + manager.SetEnabled(Assert.Single(manager.Releases), true); + var loose = Path.Combine(directory, "Plugins", "unknown.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(loose)!); + File.WriteAllText(loose, "unapproved"); + if (removeRegistry) sources = []; + else handler.Removed = true; + if (restart) manager = Create(); + var vm = new DllManagerVM(manager); + await vm.Refresh(); + if (restart) { + manager = Create(); + vm = new DllManagerVM(manager); // Cached empty approvals still display the saved pin offline. + } + var plugin = Assert.Single(vm.Plugins); + Assert.True(plugin.IsUnavailable); + Assert.True(plugin.IsEnabled); + Assert.Equal("1.0.0", plugin.EnabledVersion); + Assert.Equal("Disable", plugin.ButtonText); + Assert.Contains("no longer registered", plugin.Description); + Assert.Empty(plugin.AvailableVersions); + Assert.Equal("RemovedPlugin.dll", plugin.FileName); + Assert.False(plugin.HasHiddenEnabledVersion); + Assert.Throws(() => manager.OpenForInjection()); + await Assert.ThrowsAsync(() => manager.PrepareAsync()); + Assert.Throws(() => manager.GetUpdateCandidates(null, false)); + Assert.Throws(() => manager.SetEnabled(release, true)); + plugin.EnableOrDisableCommand.Execute(null); + Assert.Empty(vm.Plugins); + Assert.Empty(manager.EnabledSelections); + Assert.Empty(Create().EnabledSelections); + Assert.Equal("{}", File.ReadAllText(state)); + Assert.Empty(manager.GetUpdateCandidates(null, false)); + await manager.PrepareAsync(); + Assert.Empty(manager.OpenForInjection()); + Assert.Equal("unapproved", File.ReadAllText(loose)); + Assert.Empty(manager.Releases); + } + finally { Directory.Delete(directory, true); } + } + + private sealed class MutablePackageHandler(DllRelease release) : HttpMessageHandler { + public bool Removed { get; set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + var record = new PluginRelease(release.Tag!, release.Sha256, "RemovedPlugin.dll", release.DownloadUrl, release.Size, + release.ReleaseDate, release.Info, null); + var body = request.RequestUri!.AbsoluteUri == DllManager.DefaultPackageListUrl + ? (Removed ? "" : release.Id + "\n") : JsonSerializer.Serialize(new PluginPackage(release.Info!, [record])); + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new StringContent(body) }); + } + } + + [Fact] + public async Task PrereleasesAreOptInAndVersionsUseSemverPrecedence() { + var directory = Path.Combine(Path.GetTempPath(), "dll-view-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try { + var versions = new[] { "1.9.0", "1.0.0-RC1", "1.10.0", "1.0.0-rc0", "1.0.0-RC2", "1.0.0", "1.0.0-rc.2", "1.0.0-rc.10" }; + var releases = versions.Select(version => new DllRelease("Chiv2-Community", "UnchainedPlugin", version, + "https://example.com/UnchainedPlugin.dll", new string('a', 64), 1)).ToArray(); + using var http = new HttpClient(new IndexHandler(JsonSerializer.Serialize(releases))); + var manager = new DllManager(Path.Combine(directory, "Plugins"), Path.Combine(directory, "state.json"), http); + var vm = new DllManagerVM(manager); + await vm.Refresh(); + Assert.False(vm.AllowUnstablePluginReleases); + var plugin = Assert.Single(vm.Plugins); + Assert.Equal(new[] { "1.10.0", "1.9.0", "1.0.0" }, plugin.AvailableVersions.Select(row => row.Version)); + Assert.All(plugin.AvailableVersions, row => Assert.False(row.IsPrerelease)); + Assert.True(plugin.IsEnabled); + Assert.Equal("1.10.0", plugin.SelectedVersion!.Version); + + vm.AllowUnstablePluginReleases = true; + plugin = Assert.Single(vm.Plugins); + Assert.Equal(new[] { "1.10.0", "1.9.0", "1.0.0", "1.0.0-rc0", "1.0.0-rc.10", "1.0.0-rc.2", "1.0.0-RC2", "1.0.0-RC1" }, plugin.AvailableVersions.Select(row => row.Version)); + var rc = plugin.AvailableVersions.Single(row => row.Version == "1.0.0-RC2"); + Assert.True(rc.IsPrerelease); + plugin.SelectedVersion = rc; + Assert.False(manager.IsEnabled(releases.Single(release => release.Version == "1.10.0"))); + vm.AllowUnstablePluginReleases = false; + plugin = Assert.Single(vm.Plugins); + Assert.Null(plugin.SelectedVersion); + Assert.True(plugin.HasHiddenEnabledVersion); + Assert.DoesNotContain(plugin.AvailableVersions, row => row.IsPrerelease); + Assert.True(manager.IsEnabled(rc.Release)); + vm.AllowUnstablePluginReleases = true; + plugin = Assert.Single(vm.Plugins); + Assert.Equal(rc.Release, plugin.SelectedVersion!.Release); + await vm.Refresh(); + Assert.Contains(Assert.Single(vm.Plugins).AvailableVersions, row => row.IsPrerelease); + } + finally { Directory.Delete(directory, true); } + } + + [Fact] + public async Task OneCardPerPluginAndEnableDisableChangesOnlyThatPlugin() { + var directory = Path.Combine(Path.GetTempPath(), "dll-group-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try { + DllRelease Release(string org, string name, string version) => new(org, name, version, + "https://example.com/" + name + ".dll", new string('a', 64), 1); + var releases = new[] { Release("Org", "Plugin", "1.0.0"), Release("Org", "Plugin", "2.0.0"), + Release("OtherOrg", "Plugin", "1.0.0"), Release("Org", "OnlyRC", "1.0.0-RC1") }; + releases[1] = releases[1] with { Info = new PluginInfo("Display name", "Description", ["Author"], [], + "https://example.com/icon.png", ["https://example.com/screenshot.png"]) }; + using var http = new HttpClient(new IndexHandler(JsonSerializer.Serialize(releases))); + var state = Path.Combine(directory, "state.json"); + File.WriteAllText(state, "{}"); // Do not request the default plugin. + var manager = new DllManager(Path.Combine(directory, "Plugins"), state, http); + var vm = new DllManagerVM(manager); + await vm.Refresh(); + Assert.Equal(2, vm.Plugins.Count); + var plugin = vm.Plugins.Single(row => row.Organization == "Org"); + Assert.Equal(2, plugin.AvailableVersions.Count); + Assert.Equal("Display name", plugin.Name); + Assert.Equal("Description", plugin.Description); + Assert.Equal("Author", plugin.Authors); + Assert.Equal("None", plugin.Dependencies); + Assert.Equal("https://example.com/icon.png", plugin.IconUrl); + Assert.Equal(new[] { "https://example.com/screenshot.png" }, plugin.ImageUrls); + Assert.False(plugin.IsEnabled); + var notifications = new List(); + plugin.PropertyChanged += (_, args) => notifications.Add(args.PropertyName); + plugin.EnableOrDisable(); + Assert.Equal("2.0.0", plugin.SelectedVersion!.Version); + Assert.Equal("Disable", plugin.ButtonText); + Assert.Contains(nameof(plugin.SelectedVersion), notifications); + Assert.False(vm.Plugins.Single(row => row.Organization == "OtherOrg").IsEnabled); + plugin.SelectedVersion = plugin.AvailableVersions.Single(row => row.Version == "1.0.0"); + Assert.Equal("Plugin", plugin.Name); // Legacy metadata remains readable. + Assert.True(manager.IsEnabled(releases[0])); + Assert.False(manager.IsEnabled(releases[1])); + await vm.Refresh(); + plugin = vm.Plugins.Single(row => row.Organization == "Org"); + Assert.Equal("1.0.0", plugin.SelectedVersion!.Version); // Refresh does not update the selection. + plugin.EnableOrDisable(); + Assert.False(plugin.IsEnabled); + Assert.Null(plugin.SelectedVersion); + Assert.Equal("Enable", plugin.ButtonText); + Assert.False(manager.IsEnabled(releases[0])); + vm.AllowUnstablePluginReleases = true; + Assert.Equal(3, vm.Plugins.Count); + } + finally { Directory.Delete(directory, true); } + } + + [Fact] + public async Task ModernPackageCardsShowReleaseSpecificTargetsAssetsAndNotes() { + var directory = Path.Combine(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"); + var server = shared with { Name = "Server core", Target = "server" }; + var date = DateTimeOffset.Parse("2026-03-23T15:37:46+00:00"); + PluginRelease Release(string tag, PluginInfo info, string file, string notes, DateTimeOffset published) => new(tag, + new string('a', 64), file, "https://example.com/" + file, 123, published, info, notes); + var package = new PluginPackage(server, [Release("v1.0.0", shared, "Core.dll", "Stable notes", date), + Release("v2.0.0-RC1", server, "ServerCore.dll", "RC notes", date.AddDays(1))]); + using var http = new HttpClient(new PackageHandler(JsonSerializer.Serialize(package))); + var manager = new DllManager(Path.Combine(directory, "Plugins"), Path.Combine(directory, "state.json"), http); + var vm = new DllManagerVM(manager); + await vm.Refresh(); + var plugin = Assert.Single(vm.Plugins); + Assert.Equal("shared", plugin.Target); + Assert.Equal("Core.dll", plugin.FileName); + Assert.Equal("v1.0.0", plugin.ReleaseTag); + Assert.Equal("Stable notes", plugin.ReleaseNotes); + vm.AllowUnstablePluginReleases = true; + plugin = Assert.Single(vm.Plugins); + var notifications = new List(); + plugin.PropertyChanged += (_, args) => notifications.Add(args.PropertyName); + plugin.SelectedVersion = plugin.AvailableVersions.Single(row => row.IsPrerelease); + Assert.Equal("server", plugin.Target); + Assert.Equal("ServerCore.dll", plugin.FileName); + Assert.Equal("RC notes", plugin.ReleaseNotes); + Assert.Equal(date.AddDays(1).ToString("u"), plugin.ReleaseDate); + Assert.Contains(nameof(plugin.Target), notifications); + Assert.Contains(nameof(plugin.ReleaseNotes), notifications); + vm.AllowUnstablePluginReleases = false; + plugin = Assert.Single(vm.Plugins); + Assert.True(plugin.HasHiddenEnabledVersion); + Assert.Equal("Server core", plugin.Name); + Assert.Equal("server", plugin.Target); + Assert.Equal("RC notes", plugin.ReleaseNotes); + } + finally { Directory.Delete(directory, true); } + } + + private sealed class PackageHandler(string json) : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + var body = request.RequestUri!.AbsoluteUri == DllManager.DefaultPackageListUrl ? "Chiv2-Community/UnchainedPlugin\n" : json; + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new StringContent(body) }); + } + } + + private sealed class PluginSettings : IPluginSettings, System.ComponentModel.INotifyPropertyChanged { + public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; + public bool EnablePluginAutomaticUpdates { get; set; } = true; + private bool _allowUnstable; + public bool AllowUnstablePluginReleases { + get => _allowUnstable; + set { + _allowUnstable = value; + PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(AllowUnstablePluginReleases))); + } + } + } + + [Fact] + public async Task OriginalSettingsDriveDllControlsAndPrereleaseVisibility() { + var directory = Path.Combine(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, + "https://example.com/UnchainedPlugin.dll", new string('a', 64), 1)).ToArray(); + using var http = new HttpClient(new IndexHandler(JsonSerializer.Serialize(releases))); + var manager = new DllManager(Path.Combine(directory, "Plugins"), Path.Combine(directory, "state.json"), http); + var settings = new PluginSettings { EnablePluginAutomaticUpdates = false, AllowUnstablePluginReleases = true }; + var vm = new DllManagerVM(manager, settings); + await vm.Refresh(); + Assert.False(vm.EnablePluginAutomaticUpdates); + Assert.True(vm.AllowUnstablePluginReleases); + Assert.Equal(2, Assert.Single(vm.Plugins).AvailableVersions.Count); + vm.AllowUnstablePluginReleases = false; + Assert.False(settings.AllowUnstablePluginReleases); + Assert.Single(Assert.Single(vm.Plugins).AvailableVersions); + vm.EnablePluginAutomaticUpdates = true; + Assert.True(settings.EnablePluginAutomaticUpdates); + settings.AllowUnstablePluginReleases = true; + Assert.Equal(2, Assert.Single(vm.Plugins).AvailableVersions.Count); + manager.SetEnabled(manager.Releases.Single(release => release.Version == "2.0.0-RC1"), true); + Assert.Equal("2.0.0-RC1", Assert.Single(vm.Plugins).SelectedVersion!.Version); + var recreated = new DllManagerVM(manager, settings); + await recreated.Refresh(); + Assert.True(recreated.EnablePluginAutomaticUpdates); + Assert.True(recreated.AllowUnstablePluginReleases); + } + finally { Directory.Delete(directory, true); } + } + + [Fact] + public void DllManagerHasOriginalSettingsControlsWithoutPreambleOrSeparatePrereleaseToggle() { + var document = System.Xml.Linq.XDocument.Load(Path.Combine(AppContext.BaseDirectory, "Views", "MainWindow.xaml")); + System.Xml.Linq.XNamespace xaml = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + System.Xml.Linq.XNamespace language = "http://schemas.microsoft.com/winfx/2006/xaml"; + var tab = document.Descendants(xaml + "TabItem").Single(element => (string?)element.Attribute(language + "Name") == "PluginManagerTab"); + var header = Assert.Single(tab.Elements(xaml + "TabItem.Header")); + Assert.Contains(header.Descendants(xaml + "TextBlock"), element => (string?)element.Attribute("Text") == "🔌"); + Assert.Contains(header.Descendants(xaml + "TextBlock"), element => (string?)element.Attribute("Text") == "Plugin Manager"); + var controls = tab.Descendants(xaml + "CheckBox").ToArray(); + Assert.Equal(2, controls.Length); + Assert.Equal("Enable Automatic Plugin Updates and Downloads", (string?)controls[0].Attribute("Content")); + Assert.Equal("{Binding EnablePluginAutomaticUpdates}", (string?)controls[0].Attribute("IsChecked")); + Assert.Equal("Enable Unstable Plugin Releases", (string?)controls[1].Attribute("Content")); + Assert.Equal("{Binding AllowUnstablePluginReleases}", (string?)controls[1].Attribute("IsChecked")); + Assert.Equal("{Binding EnablePluginAutomaticUpdates}", (string?)controls[1].Attribute("IsEnabled")); + Assert.DoesNotContain("UnchainedPlugin is enabled by default", tab.ToString()); + Assert.DoesNotContain("Show prereleases", tab.ToString()); + var footer = controls[0].Parent!; + Assert.Same(footer, controls[1].Parent); + Assert.Equal("2", (string?)footer.Attribute("Grid.Row")); + Assert.Equal("1", (string?)controls[0].Attribute("Grid.Row")); + Assert.Equal("1", (string?)controls[1].Attribute("Grid.Row")); + Assert.Equal("0", (string?)controls[0].Attribute("Grid.Column")); + Assert.Equal("1", (string?)controls[1].Attribute("Grid.Column")); + var divider = Assert.Single(footer.Elements(xaml + "Border")); + Assert.Equal("0", (string?)divider.Attribute("Grid.Row")); + Assert.Equal("2", (string?)divider.Attribute("Grid.ColumnSpan")); + Assert.Equal("{DynamicResource Brush.Border}", (string?)divider.Attribute("BorderBrush")); + Assert.Equal("0,1,0,0", (string?)divider.Attribute("BorderThickness")); + Assert.Equal("True", (string?)divider.Attribute("SnapsToDevicePixels")); + Assert.DoesNotContain(tab.Descendants(xaml + "TextBlock"), element => (string?)element.Attribute("Text") == "{Binding Status}"); + var toolbar = tab.Descendants(xaml + "Grid").Single(element => (string?)element.Attribute("Grid.Row") == "0" + && element.Elements(xaml + "TextBox").Any()); + Assert.Equal("{Binding SearchTerm, UpdateSourceTrigger=PropertyChanged}", (string?)Assert.Single(toolbar.Elements(xaml + "TextBox")).Attribute("Text")); + Assert.Equal("{Binding SelectedSortMode}", (string?)Assert.Single(toolbar.Elements(xaml + "ComboBox")).Attribute("SelectedItem")); + var update = toolbar.Elements(xaml + "Button").Single(element => (string?)element.Attribute("Content") == "Update Plugins"); + Assert.Equal("{Binding UpdatePluginsCommand}", (string?)update.Attribute("Command")); + var refresh = toolbar.Elements(xaml + "Button").Single(element => (string?)element.Attribute("Content") == "Refresh Plugin List"); + Assert.Equal("{StaticResource Button.Primary}", (string?)refresh.Attribute("Style")); + Assert.Equal("{Binding RefreshCommand}", (string?)refresh.Attribute("Command")); + Assert.Contains(tab.Descendants(xaml + "Button"), element => (string?)element.Attribute("Command") == "{Binding ViewOnGithubCommand}"); + Assert.Contains(tab.Descendants(xaml + "Button"), element => (string?)element.Attribute("Command") == "{Binding OpenGithubReleaseCommand}"); + } + + private sealed class MutableIndexHandler : HttpMessageHandler { + public string Json { get; set; } = "[]"; + public bool Fail { get; set; } + public List Requests { get; } = []; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + Requests.Add(request.RequestUri!.AbsoluteUri); + if (Fail) throw new HttpRequestException("Offline fixture"); + return Task.FromResult(new HttpResponseMessage(request.RequestUri.AbsoluteUri == DllManager.DefaultPackageListUrl + ? System.Net.HttpStatusCode.NotFound : System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new StringContent(Json) }); + } + } + + [Fact] + public async Task PluginLinksOpenRepositoryAndOriginalDisplayedTagWithoutChangingSelection() { + var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "plugin-links-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try { + var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { + Release("Plugin"), Release("Plugin", "2.0.0-RC1+build.abc") + }) }; + using var http = new HttpClient(handler); + var state = Path.Combine(directory, "state.json"); + File.WriteAllText(state, "{}"); + var manager = new DllManager(Path.Combine(directory, "Plugins"), state, http); + await manager.RefreshAsync(); + var opened = new List(); + var errors = new List(); + var plugin = new DllPluginVM(manager, manager.Releases, true, errors.Add, opened.Add); + plugin.ViewOnGithubCommand.Execute(null); + plugin.OpenGithubReleaseCommand.Execute(null); + Assert.Equal(new[] { "https://github.com/Chiv2-Community/Plugin", + "https://github.com/Chiv2-Community/Plugin/releases/tag/v2.0.0-RC1%2Bbuild.abc" }, opened); + Assert.Empty(manager.Releases.Where(manager.IsEnabled)); + var changed = new List(); + plugin.PropertyChanged += (_, args) => changed.Add(args.PropertyName); + plugin.SelectedVersion = plugin.AvailableVersions.Single(row => !row.IsPrerelease); + plugin.OpenGithubReleaseCommand.Execute(null); + Assert.Equal("https://github.com/Chiv2-Community/Plugin/releases/tag/v1.0.0", opened.Last()); + Assert.Contains(nameof(plugin.GithubReleaseUrl), changed); + plugin.SelectedVersion = plugin.AvailableVersions.Single(row => row.IsPrerelease); + var hidden = new DllPluginVM(manager, manager.Releases, false, errors.Add, opened.Add); + Assert.True(hidden.HasHiddenEnabledVersion); + hidden.OpenGithubReleaseCommand.Execute(null); + Assert.Equal("https://github.com/Chiv2-Community/Plugin/releases/tag/v2.0.0-RC1%2Bbuild.abc", opened.Last()); + Assert.Empty(errors); + var failing = new DllPluginVM(manager, manager.Releases, true, errors.Add, + _ => throw new InvalidOperationException("Browser unavailable")); + failing.ViewOnGithubCommand.Execute(null); + Assert.Contains("Could not open GitHub link", Assert.Single(errors)); + } + finally { Directory.Delete(directory, true); } + } + + private static DllRelease Release(string name, string version = "1.0.0", string date = "2026-01-01T00:00:00Z", string target = "shared") => + new("Chiv2-Community", name, version, "https://example.com/" + name + ".dll", new string('a', 64), 1, + new PluginInfo(name + " tools", "Useful widgets", ["Jane"], Target: target), "v" + version, DateTimeOffset.Parse(date)); + + [Fact] + public async Task PluginSearchAndSharedSortModesRebuildWithoutChangingSelections() { + var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "plugin-sort-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try { + var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { + Release("Alpha", date: "2020-01-01T00:00:00Z"), Release("Alpha", "2.0.0", "2026-04-01T00:00:00Z"), + Release("Beta", date: "2025-01-01T00:00:00Z"), Release("Beta", "2.0.0", "2026-02-01T00:00:00Z"), + Release("Gamma", date: "2024-01-01T00:00:00Z"), Release("OnlyRC", "1.0.0-RC1") + }) }; + using var http = new HttpClient(handler); + var state = Path.Combine(directory, "state.json"); + File.WriteAllText(state, "{}"); + var manager = new DllManager(Path.Combine(directory, "Plugins"), state, http); + var vm = new DllManagerVM(manager); + await vm.Refresh(); + manager.SetEnabled(manager.Releases.Single(release => release.Name == "Gamma"), true); + Assert.Equal(new[] { "Gamma", "Alpha", "Beta" }, vm.Plugins.Select(plugin => plugin.RepositoryName)); + vm.SelectedSortMode = ModSortMode.Alphabetical; + Assert.Equal(new[] { "Alpha", "Beta", "Gamma" }, vm.Plugins.Select(plugin => plugin.RepositoryName)); + vm.SelectedSortMode = ModSortMode.LatestReleaseDateFirst; + Assert.Equal(new[] { "Alpha", "Beta", "Gamma" }, vm.Plugins.Select(plugin => plugin.RepositoryName)); + vm.SelectedSortMode = ModSortMode.NewestModsFirst; + Assert.Equal(new[] { "Beta", "Gamma", "Alpha" }, vm.Plugins.Select(plugin => plugin.RepositoryName)); + vm.SearchTerm = " ALPHA tools "; + Assert.Equal("Alpha", Assert.Single(vm.Plugins).RepositoryName); + vm.SearchTerm = "community/beta"; + Assert.Equal("Beta", Assert.Single(vm.Plugins).RepositoryName); + vm.SearchTerm = "jane"; + Assert.Equal(3, vm.Plugins.Count); + vm.SearchTerm = "widgets"; + Assert.Equal(3, vm.Plugins.Count); + vm.SearchTerm = "missing"; + Assert.Empty(vm.Plugins); + vm.SearchTerm = ""; + Assert.Equal(new[] { "Beta", "Gamma", "Alpha" }, vm.Plugins.Select(plugin => plugin.RepositoryName)); + vm.AllowUnstablePluginReleases = true; + Assert.Equal(4, vm.Plugins.Count); + Assert.Equal("Gamma", Assert.Single(manager.Releases.Where(manager.IsEnabled)).Name); + await vm.Refresh(); + Assert.Equal(ModSortMode.NewestModsFirst, vm.SelectedSortMode); + manager.SetEnabled(manager.Releases.Single(release => release.Name == "OnlyRC"), true); + vm.AllowUnstablePluginReleases = false; + var hidden = vm.Plugins.Single(plugin => plugin.RepositoryName == "OnlyRC"); + Assert.True(hidden.HasHiddenEnabledVersion); + Assert.Empty(hidden.AvailableVersions); + hidden.EnableOrDisable(); + Assert.DoesNotContain(vm.Plugins, plugin => plugin.RepositoryName == "OnlyRC"); + } + finally { Directory.Delete(directory, true); } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [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")); + Directory.CreateDirectory(directory); + try { + var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { + Release("UnchainedPlugin"), Release("ServerPlugin", target: "server"), Release("DisabledPlugin") + }) }; + using var http = new HttpClient(handler); + var manager = new DllManager(Path.Combine(directory, "Plugins"), Path.Combine(directory, "state.json"), http); + var dialogue = new RecordingDialogue(approve); + var settings = new PluginSettings { EnablePluginAutomaticUpdates = false, AllowUnstablePluginReleases = unstable }; + var vm = new DllManagerVM(manager, settings, dialogue); + await vm.Refresh(); + manager.SetEnabled(manager.Releases.Single(release => release.Name == "ServerPlugin"), true); + handler.Json = JsonSerializer.Serialize(new[] { + Release("UnchainedPlugin"), Release("UnchainedPlugin", "2.0.0+build.abc"), Release("UnchainedPlugin", "3.0.0-RC1+build.abc"), + Release("ServerPlugin", target: "server"), Release("ServerPlugin", "2.0.0", target: "server"), + Release("DisabledPlugin"), Release("DisabledPlugin", "2.0.0") + }); + await vm.UpdatePlugins(); + Assert.Equal(2, dialogue.Updates.Count); + var newest = unstable ? "3.0.0-RC1+build.abc" : "2.0.0+build.abc"; + Assert.Equal(newest, dialogue.Updates.Single(update => update.Name == "UnchainedPlugin tools").LatestVersion); + Assert.Equal($"https://github.com/{DllManager.DefaultPluginId}/releases/tag/v{Uri.EscapeDataString(newest)}", + dialogue.Updates.Single(update => update.Name == "UnchainedPlugin tools").ReleaseUrl); + Assert.All(dialogue.Updates, update => Assert.Contains("/releases/tag/v", update.ReleaseUrl)); + Assert.Equal(approve ? newest : "1.0.0", manager.Releases.Single(release => release.Name == "UnchainedPlugin" && manager.IsEnabled(release)).Version); + Assert.Equal(approve ? "2.0.0" : "1.0.0", manager.Releases.Single(release => release.Name == "ServerPlugin" && manager.IsEnabled(release)).Version); + Assert.DoesNotContain(manager.Releases.Where(manager.IsEnabled), release => release.Name == "DisabledPlugin"); + Assert.False(settings.EnablePluginAutomaticUpdates); + Assert.DoesNotContain(handler.Requests, url => url.EndsWith(".dll")); + dialogue.Updates.Clear(); + vm.SearchTerm = "not found"; + await vm.UpdatePlugins(); + Assert.Empty(dialogue.Updates); + Assert.Empty(dialogue.Messages); + handler.Fail = true; + await vm.UpdatePlugins(); + Assert.StartsWith("Could not update plugins:", Assert.Single(dialogue.Messages)); + } + finally { Directory.Delete(directory, true); } + } + + private sealed class IndexHandler(string json) : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(request.RequestUri!.AbsoluteUri == DllManager.DefaultPackageListUrl + ? System.Net.HttpStatusCode.NotFound : System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new StringContent(json) }); + } + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/DllSelectionRefreshTests.cs b/UnchainedLauncher.GUI.Tests.Unit/DllSelectionRefreshTests.cs new file mode 100644 index 00000000..2c8e92c6 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/DllSelectionRefreshTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Text.Json; +using LanguageExt; +using UnchainedLauncher.Core.Services.Dlls; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.GUI.ViewModels; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit; + +public sealed class DllSelectionRefreshTests { + private sealed class Handler : HttpMessageHandler { + public string Json { get; set; } = "[]"; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(request.RequestUri!.AbsoluteUri.EndsWith("plugin_list_index.txt") + ? HttpStatusCode.NotFound : HttpStatusCode.OK) { + RequestMessage = request, Content = new StringContent(Json) + }); + } + + [Theory] + [InlineData("unchanged")] + [InlineData("modified")] + [InlineData("revoked")] + public async Task ExistingCardAfterLaunchRefreshRequiresCurrentCompleteApproval(string change) { + var directory = Path.Combine(Path.GetTempPath(), "dll-refresh-selection-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try { + var release = new DllRelease("Chiv2-Community", "UnchainedPlugin", "1.0.0", "https://example.test/Core.dll", + new string('a', 64), 123, new PluginInfo("Unchained Core Plugin", "test metadata", ["Author"], ImageUrls: ["https://example.test/image.png"])); + var handler = new Handler { Json = JsonSerializer.Serialize(new[] { release }) }; + using var http = new HttpClient(handler); + var manager = new DllManager(Path.Combine(directory, "Plugins"), Path.Combine(directory, "state.json"), http); + var dialogue = new RecordingDialogue(); + var vm = new DllManagerVM(manager, dialogue: dialogue); + await vm.Refresh(); + Assert.Single(vm.Plugins).EnableOrDisable(); // Disable the initially enabled core plugin. + var oldCard = Assert.Single(vm.Plugins); + Assert.False(oldCard.IsEnabled); + handler.Json = change switch { + "modified" => JsonSerializer.Serialize(new[] { release with { Info = release.Info! with { Description = "changed approval" } } }), + "revoked" => "[]", + _ => handler.Json + }; + var preparer = new DllLaunchPreparer(manager, null!); // No enabled plugins: no prompts or downloads. + var options = new LaunchOptions([], Option.None, "", true, false, Option.None, Option.None); + Assert.True((await preparer.PrepareLaunch(options)).IsSome); + if (change != "revoked") Assert.NotSame(oldCard.AvailableVersions[0].Release, Assert.Single(manager.Releases)); + oldCard.EnableOrDisable(); + if (change == "unchanged") { + Assert.True(oldCard.IsEnabled); + Assert.Equal("1.0.0", manager.EnabledSelections[DllManager.DefaultPluginId]); + Assert.Empty(dialogue.Messages); + } + else { + Assert.False(oldCard.IsEnabled); + Assert.Empty(manager.EnabledSelections); + Assert.Contains("Unregistered", Assert.Single(dialogue.Messages)); + } + } + finally { Directory.Delete(directory, true); } + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/PluginReleaseDefaultsTests.cs b/UnchainedLauncher.GUI.Tests.Unit/PluginReleaseDefaultsTests.cs new file mode 100644 index 00000000..57e6e472 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/PluginReleaseDefaultsTests.cs @@ -0,0 +1,25 @@ +using Semver; +using UnchainedLauncher.GUI.JsonModels; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit { + public sealed class PluginReleaseDefaultsTests { + [Theory] + [InlineData("1.0.0-RC4", "1.0.0-RC4", false, true)] + [InlineData("1.0.0-RC4", "1.0.0-RC4", null, true)] + [InlineData("1.0.0-RC4", "0.5.0", false, true)] + [InlineData("1.0.0-RC4", null, false, true)] + [InlineData("1.0.0", "1.0.0", true, true)] + [InlineData("1.0.0", "1.0.0", false, false)] + [InlineData("1.0.0", "1.0.0", null, false)] + [InlineData("1.0.0", "1.0.0-RC4", true, false)] + [InlineData("1.0.0", "0.5.0", true, false)] + [InlineData("1.0.0", null, true, false)] + [InlineData("1.0.0+build.2", "1.0.0+build.1", true, true)] + public void PrereleaseBuildsEnableUnstablePluginsRegardlessOfSavedPreference(string current, string? previous, bool? saved, bool expected) { + var settings = new LauncherSettings(null, null, null, null, null, null, saved, + previous == null ? null : SemVersion.Parse(previous, SemVersionStyles.Strict)); + Assert.Equal(expected, settings.ShouldAllowUnstablePluginReleases(SemVersion.Parse(current, SemVersionStyles.Strict))); + } + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/PluginReleaseNotesCardTests.cs b/UnchainedLauncher.GUI.Tests.Unit/PluginReleaseNotesCardTests.cs new file mode 100644 index 00000000..a0123a67 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/PluginReleaseNotesCardTests.cs @@ -0,0 +1,18 @@ +using System.Xml.Linq; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit; + +public sealed class PluginReleaseNotesCardTests { + [Fact] + public void ReleaseNotesUseSharedCollapsibleCardAndRetainBindings() { + var document = XDocument.Load(Path.Combine(AppContext.BaseDirectory, "Views", "MainWindow.xaml")); + XNamespace controls = "clr-namespace:UnchainedLauncher.GUI.Views.Controls"; + var card = Assert.Single(document.Descendants(controls + "CollapsibleCard") + .Where(element => (string?)element.Attribute("Header") == "Release notes")); + Assert.Equal("False", (string?)card.Attribute("IsExpanded")); + var body = Assert.Single(card.Elements(controls + "CollapsibleCard.Body")); + Assert.Contains(body.Descendants(), element => (string?)element.Attribute("Text") == "{Binding ReleaseNotes}"); + Assert.Contains(body.Descendants(), element => (string?)element.Attribute("Command") == "{Binding OpenGithubReleaseCommand}"); + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/RecordingDialogue.cs b/UnchainedLauncher.GUI.Tests.Unit/RecordingDialogue.cs new file mode 100644 index 00000000..a860f466 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/RecordingDialogue.cs @@ -0,0 +1,23 @@ +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.GUI.Tests.Unit; + +internal sealed class RecordingDialogue(bool? approveUpdates = null) : IUserDialogueSpawner { + public List Messages { get; } = []; + public List Updates { get; } = []; + + public void DisplayMessage(string message) => Messages.Add(message); + + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) => + throw new NotSupportedException("Unexpected yes/no prompt."); + + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, + string noButtonText, string? cancelButtonText, IEnumerable updates) { + if (approveUpdates == null) throw new NotSupportedException("Unexpected update prompt."); + Updates.AddRange(updates); + return approveUpdates.Value ? UserDialogueChoice.Yes : UserDialogueChoice.No; + } + + public Action DisplayProgress(MemoryProgress progress) => throw new NotSupportedException("Unexpected progress window."); +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs new file mode 100644 index 00000000..fed6b994 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Dlls; +using UnchainedLauncher.GUI.ViewModels.Registry; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit { + public sealed class RegistryReleaseFormVMTests { + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [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"); + File.WriteAllText(path, "pak fixture"); + try { + var pak = new Dependency("https://github.com/Org/Pak", ">=1.0.0"); + var plugin = new Dependency("https://github.com/Org/Plugin", ">=1.0.0"); + var info = new ModInfo("Org/Mod", "Mod", "Original", null, null, [], [], + Dependencies: modernPakDependencies ? null : [pak], + ModDependencies: modernPakDependencies ? [pak] : null, PluginDependencies: [plugin]); + var original = new Release("v1.0.0", "hash", "test.pak", new DateTime(2026, 1, 1), info, + new AssetCollections([], [], [], [], []), "notes"); + var input = newRelease ? original with { Tag = "v1.0.1", ReleaseDate = original.ReleaseDate.AddDays(1) } : original; + var form = new RegistryReleaseFormVM(input, path) { Description = "Edited" }; + var (edited, pakPath) = form.ToRelease(); + Assert.Equal(path, pakPath); + Assert.Equal(input.Tag, edited.Tag); + Assert.Equal(input.ReleaseDate, edited.ReleaseDate); + Assert.Equal("Edited", edited.Info.Description); + Assert.Equal(new[] { pak }, edited.Info.EffectiveModDependencies); + Assert.Equal(new[] { plugin }, edited.Info.EffectivePluginDependencies); + Assert.Same(info.PluginDependencies, edited.Info.PluginDependencies); + var persisted = JsonSerializer.Deserialize(JsonSerializer.Serialize(edited))!; + Assert.Equal(new[] { pak }, persisted.Info.EffectiveModDependencies); + Assert.Equal(new[] { plugin }, persisted.Info.EffectivePluginDependencies); + Assert.Throws(() => DependencyValidator.Validate([persisted], [])); + Assert.Equal("Original", original.Info.Description); + } + finally { File.Delete(path); } + } + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/ReleaseBadgeTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ReleaseBadgeTests.cs new file mode 100644 index 00000000..58c3a7a2 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/ReleaseBadgeTests.cs @@ -0,0 +1,62 @@ +using System.Xml.Linq; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit { + public sealed class ReleaseBadgeTests { + [Theory] + [InlineData("v1.0.0-RC2", true)] + [InlineData("v1.0.0-RC1", true)] + [InlineData("v1.0.0-rc0", true)] + [InlineData("v1.0.0", false)] + public void ModBadgeUsesSemverPrereleaseClassification(string tag, bool prerelease) { + var release = new Release(tag, "hash", "mod.pak", DateTime.UtcNow, null!, null!, null); + Assert.Equal(prerelease, release.Version!.IsPrerelease); + } + + [Fact] + public void BothManagersUseTheSameBadgeTemplate() { + var mods = Load("Mod.xaml"); + var dlls = Load("MainWindow.xaml"); + var theme = Load("Misc.xaml"); + var modBadges = Badges(mods).ToArray(); + Assert.Equal(2, modBadges.Length); // Version selector and dependency versions. + Assert.All(modBadges, badge => Assert.Contains("Version.IsPrerelease", badge.Attribute("Visibility")!.Value)); + var dllBadge = Assert.Single(Badges(dlls)); + Assert.Contains("IsPrerelease", dllBadge.Attribute("Visibility")!.Value); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var style = Assert.Single(theme.Descendants().Where(element => element.Attribute(x + "Key")?.Value == "Release.PrereleaseBadge")); + Assert.Contains(style.Descendants(), element => element.Attribute("Text")?.Value == "Prerelease"); + Assert.DoesNotContain(mods.Descendants(), element => element.Name.LocalName == "ComboBox" && element.Attribute("DisplayMemberPath") != null); + Assert.Contains(mods.Descendants(), element => element.Attribute("Style")?.Value == "{StaticResource PluginCard}"); + Assert.Contains(dlls.Descendants(), element => element.Attribute("Style")?.Value == "{StaticResource PluginCard}"); + Assert.Contains(dlls.Descendants(), element => element.Name.LocalName == "ComboBox" && element.Attribute("ItemsSource")?.Value == "{Binding AvailableVersions}"); + } + + [Fact] + public void BothManagersUseTheSameEnableButtonStyle() { + foreach (var name in new[] { "Mod.xaml", "MainWindow.xaml" }) { + var button = Assert.Single(Load(name).Descendants().Where(element => element.Name.LocalName == "Button" + && element.Attribute("Command")?.Value == "{Binding EnableOrDisableCommand}")); + Assert.Equal("{StaticResource Button.EnableOrDisable}", button.Attribute("Style")?.Value); + Assert.Empty(button.Elements()); + } + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var style = Assert.Single(Load("Misc.xaml").Descendants().Where(element => element.Attribute(x + "Key")?.Value == "Button.EnableOrDisable")); + Assert.Equal("{StaticResource Button.Secondary}", style.Attribute("BasedOn")?.Value); + var triggers = style.Descendants().Where(element => element.Name.LocalName == "DataTrigger").ToArray(); + Assert.Equal(2, triggers.Length); + foreach (var (enabled, brush) in new[] { ("False", "Success"), ("True", "Danger") }) { + var trigger = Assert.Single(triggers.Where(element => element.Attribute("Value")?.Value == enabled)); + Assert.Equal("{Binding IsEnabled}", trigger.Attribute("Binding")?.Value); + var setter = Assert.Single(trigger.Elements()); + Assert.Equal("Background", setter.Attribute("Property")?.Value); + Assert.Equal($"{{DynamicResource Brush.{brush}}}", setter.Attribute("Value")?.Value); + } + } + + private static XDocument Load(string name) => XDocument.Load(Path.Combine(AppContext.BaseDirectory, "Views", name)); + private static IEnumerable Badges(XDocument document) => document.Descendants() + .Where(element => element.Attribute("Style")?.Value == "{StaticResource Release.PrereleaseBadge}"); + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj new file mode 100644 index 00000000..b857599c --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + + diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 9636d494..7777a0f6 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -1,427 +1,424 @@ -using LanguageExt; -using log4net; -using System; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using System.Windows; -using UnchainedLauncher.Core.Extensions; -using UnchainedLauncher.Core.Services; -using UnchainedLauncher.Core.Services.Installer; -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.GUI.ViewModels; -using UnchainedLauncher.GUI.ViewModels.Installer; -using UnchainedLauncher.GUI.ViewModels.Registry; -using UnchainedLauncher.GUI.ViewModels.ServersTab; -using UnchainedLauncher.GUI.Views; -using UnchainedLauncher.GUI.Views.Installer; -using UnchainedLauncher.UnrealModScanner.GUI.ViewModels; -using Application = System.Windows.Application; - -namespace UnchainedLauncher.GUI { - using static LanguageExt.Prelude; - - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application { - private static readonly ILog _log = LogManager.GetLogger(typeof(App)); - - protected override async void OnStartup(StartupEventArgs e) { - try { - base.OnStartup(e); - - AppDomain.CurrentDomain.UnhandledException += - (sender, args) => { - var ex = (Exception)args.ExceptionObject; - _log.Fatal("Unhandled exception", ex); - - File.WriteAllText("crash.log", ex.ToString()); - var currentDirectory = Directory.GetCurrentDirectory(); - MessageBox.Show($"An unhandled exception occurred. Please report this to a developer with {currentDirectory}\\crash.log ", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - }; - - // Capture the UI thread's SynchronizationContext for use throughout the application - UISynchronizationContext.Initialize(); - - var assembly = Assembly.GetExecutingAssembly(); - if (File.Exists("log4net.config")) { - log4net.Config.XmlConfigurator.Configure(new FileInfo("log4net.config")); - } - else if (File.Exists("Resources/log4net.config")) { - // for running in Visual Studio - log4net.Config.XmlConfigurator.Configure(new FileInfo("Resources/log4net.config")); - } - else { - using var configStream = - assembly.GetManifestResourceStream("UnchainedLauncher.GUI.Resources.log4net.config"); - if (configStream != null) { - log4net.Config.XmlConfigurator.Configure(configStream); - } - } - - // Init common dependencies - var githubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("UnchainedLauncher")); - var unchainedLauncherReleaseLocator = - new GithubReleaseLocator(githubClient, "Chiv2-Community", "UnchainedLauncher"); - var pluginReleaseLocator = new GithubReleaseLocator(githubClient, "Chiv2-Community", "UnchainedPlugin"); - - - var installationFinder = new Chivalry2InstallationFinder(); - var installer = new UnchainedLauncherInstaller(Shutdown); - - // figure out if we need to install by checking our current working directory - var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory()); - var needsInstallation = !installationFinder.IsValidInstallation(currentDirectory); - - var forceSkipInstallation = Environment.GetCommandLineArgs().ToList().Contains("--no-install"); - - if (forceSkipInstallation && needsInstallation) - _log.Info("Skipping installation"); - - var window = - needsInstallation && !forceSkipInstallation - ? InitializeInstallerWindow(installationFinder, installer, unchainedLauncherReleaseLocator) - : await InitializeMainWindow(installationFinder, installer, unchainedLauncherReleaseLocator, - pluginReleaseLocator); - - window?.Show(); - } - catch (Exception ex) { - Debug.WriteLine($"Error starting application: {ex.Message}"); - if (ex.StackTrace != null) - Debug.WriteLine(ex.StackTrace!); - - _log.Fatal("Unhandled exception", ex); - File.WriteAllText("crash.log", ex.ToString()); - var currentDirectory = Directory.GetCurrentDirectory(); - MessageBox.Show($"An unhandled exception occurred. Please report this to a developer with {currentDirectory}\\crash.log ", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - // Removed class-level command bindings; handled by Views.UnchainedWindow type. - - private Window? InitializeInstallerWindow(Chivalry2InstallationFinder installationFinder, - IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator) { - var installationSelectionVM = new InstallationSelectionPageViewModel(installationFinder); - var versionSelectionVM = new VersionSelectionPageViewModel(launcherReleaseLocator, new FileInfoVersionExtractor()); - var installationLogVM = new InstallerLogPageViewModel( - installer, - () => - from chiv2Installations in installationSelectionVM.Installations - where chiv2Installations.IsSelected - select chiv2Installations.Path - , - () => versionSelectionVM.SelectedVersion! - ); - - var installerPageViewModels = new ObservableCollection { - installationSelectionVM, versionSelectionVM, installationLogVM - }; - - var installerWindowVM = - new InstallerWindowViewModel(installerPageViewModels, installationSelectionVM.Installations); - return new InstallerWindow(installerWindowVM); - } - - private async Task InitializeMainWindow(IChivalry2InstallationFinder installationFinder, - IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator, - IReleaseLocator pluginReleaseLocator) { - var userDialogueSpawner = new MessageBoxSpawner(); - var registryWindowService = new RegistryWindowService(); - - var modRegistry = InitializeModRegistry(FilePaths.RegistryConfigPath); - var modManager = InitializeModManager(FilePaths.ModManagerConfigPath, modRegistry); - - var registryWindowViewModel = new RegistryWindowVM(modRegistry, registryWindowService); - - var settingsViewModel = InitializeSettingsViewModel(installationFinder, installer, launcherReleaseLocator, registryWindowViewModel, registryWindowService, modManager, userDialogueSpawner); - -#if DEBUG_FAKECHIVALRYLAUNCH - var officialProcessLauncher = new PowershellProcessLauncher( - "Official Chivalry 2" - ); - var unchainedProcessLauncher = new PowershellProcessLauncher( - "Unchained Chivalry 2" - ); -#else - var originalLauncherPath = File.Exists(FilePaths.OriginalLauncherPath) - ? FilePaths.OriginalLauncherPath - : FilePaths.LauncherPath; - var officialProcessLauncher = - new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), originalLauncherPath)); - var unchainedProcessLauncher = - new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), FilePaths.GameBinPath)); -#endif - - var noSigLaunchPreparer = NoSigPreparer.Create(modManager.PakDir, userDialogueSpawner); - var sigLaunchPreparer = SigPreparer.Create(modManager.PakDir, userDialogueSpawner); - - IChivalry2LaunchPreparer pluginInstaller = new UnchainedPluginUpdateChecker( - pluginReleaseLocator, - new FileInfoVersionExtractor(), - userDialogueSpawner); - - IChivalry2LaunchPreparer modInstaller = new Chivalry2ModsInstaller( - modManager, userDialogueSpawner - ); - - var vanillaLauncher = new Chivalry2Launcher( - noSigLaunchPreparer.IgnoreOptions(), - officialProcessLauncher, - Directory.GetCurrentDirectory() - ); - - var clientsideModdedLauncher = new Chivalry2Launcher( - modInstaller - .Sub(sigLaunchPreparer), - officialProcessLauncher, - Directory.GetCurrentDirectory() - ); - - var unchainedLauncher = new UnchainedChivalry2Launcher( - pluginInstaller - .AndThen(modInstaller) - .Sub(sigLaunchPreparer), - 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(Path.Combine(Directory.GetCurrentDirectory(), FilePaths.PluginDir)) -#endif - ); - - var chivProcessMonitor = new ChivalryProcessWatcher(); - - var homeViewModel = new HomeVM( - settingsViewModel, - modManager, - vanillaLauncher, - unchainedLauncher, - userDialogueSpawner, - chivProcessMonitor); - var modListViewModel = new ModListVM(modManager, userDialogueSpawner); - - if (modManager.NeedsMetadataRefresh || !modManager.Mods.Any()) - await modListViewModel.RefreshModListCommand.ExecuteAsync(null); - - // Rebuild the arguments used to launch the application - var envArgs = Environment.GetCommandLineArgs().Skip(1).ToList(); - - // TODO: Replace this if/else chain with a real CLI - if (envArgs.Contains("--startvanilla")) { - homeViewModel.LaunchVanilla().Wait(); - return null; - } - - if (envArgs.Contains("--startunchained")) { - homeViewModel.LaunchUnchained().Wait(); - return null; - } - - var modScanTab = new ModScanTabVM(); - var helpVM = new HelpVM(settingsViewModel, installer, launcherReleaseLocator, modManager.PakDir, userDialogueSpawner, Shutdown); - var availableModsAndMaps = new AvailableModsAndMapsService(modManager, modScanTab); - - var serverConfigurationVMs = - InitializeServerConfigurations(FilePaths.ServerConfigurationsFilePath, modManager, modScanTab, availableModsAndMaps); - - var serversTabViewModel = InitializeServersTab( - FilePaths.ServersTabConfigurationPath, - settingsViewModel, - modManager, - modScanTab, - userDialogueSpawner, - unchainedLauncher, - serverConfigurationVMs, - chivProcessMonitor, - availableModsAndMaps - ); - - var mainWindowViewModel = new MainWindowVM( - homeViewModel, - modListViewModel, - settingsViewModel, - serversTabViewModel, - modScanTab, - helpVM - ); - - return new MainWindow(mainWindowViewModel); - } - - private SettingsVM InitializeSettingsViewModel(IChivalry2InstallationFinder installationFinder, - IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator, - RegistryWindowVM registryWindowViewModel, RegistryWindowService registryWindowService, ModManager modManager, - MessageBoxSpawner userDialogueSpawner) { - var cliArgs = string.Join(" ", - Environment.GetCommandLineArgs() - .Skip(1) - .ToList() - .Select(ArgumentEscaper.Escape) - ); - - var settingsCodec = new SettingsCodec( - registryWindowViewModel, - registryWindowService, - installationFinder, - cliArgs - ); - - var settingsViewModel = InitializeFromFileWithCodec( - settingsCodec, - FilePaths.LauncherSettingsFilePath, - () => new SettingsVM( - registryWindowViewModel, - registryWindowService, - SettingsVM.DetectInstallationType(installationFinder), - true, - false, - "", - "https://servers.polehammer.net", - false, - SettingsVM.Version.IsPrerelease, - cliArgs - ) - ); - - RegisterSaveToFileOnExit(settingsViewModel, settingsCodec, FilePaths.LauncherSettingsFilePath); - return settingsViewModel; - } - - private AggregateModRegistry InitializeModRegistry(string jsonPath) { - AggregateModRegistry CreateDefaultModRegistry() => new AggregateModRegistry( - new GithubModRegistry("Chiv2-Community", "C2ModRegistry")); - - var loadedResult = - InitializeFromFileWithCodec(ModRegistryCodec.Instance, jsonPath, CreateDefaultModRegistry); - // Ensure that we've got an AggregateModRegistry. The constructor will handle it if we're wrapping - // another AggregateModRegistry, so no worries there. - var registry = new AggregateModRegistry(loadedResult); - - RegisterSaveToFileOnExit(registry, ModRegistryCodec.Instance, jsonPath); - return registry; - } - - private ModManager InitializeModManager(string jsonPath, IModRegistry registry) { - Func initializeDefaultModManager = () => - new ModManager( - registry, - new PakDir(FilePaths.PakDir, Enumerable.Empty()), - Enumerable.Empty() - ); - - var codec = new ModManagerCodec(registry); - var modManager = InitializeFromFileWithCodec(codec, jsonPath, initializeDefaultModManager); - - RegisterSaveToFileOnExit(modManager, codec, jsonPath); - return modManager; - } - - private ObservableCollection InitializeServerConfigurations(string jsonPath, - IModManager modManager, ModScanTabVM modScanTab, AvailableModsAndMapsService availableModsAndMapsVM) { - Func> initializeDefault = - () => new ObservableCollection(); - - var codec = new ServerConfigurationCodec(modManager, modScanTab, availableModsAndMapsVM); - var serverConfigurations = InitializeFromFileWithCodec(codec, jsonPath, initializeDefault); - - RegisterSaveToFileOnExit(serverConfigurations, codec, jsonPath); - Exit += (_, _) => serverConfigurations.ForEach(x => x.SaveINI()); - - return serverConfigurations; - } - - private ServersTabVM InitializeServersTab( - string jsonPath, - SettingsVM settings, - IModManager modManager, - ModScanTabVM modScanTab, - IUserDialogueSpawner dialogueSpawner, - IChivalry2Launcher launcher, - ObservableCollection serverConfigurations, - IChivalryProcessWatcher processWatcher, - AvailableModsAndMapsService availableModsAndMaps) { - Func initializeDefault = () => new ServersTabVM( - settings, - modManager, - modScanTab, - dialogueSpawner, - launcher, - serverConfigurations, - processWatcher, - availableModsAndMaps - ); - - var codec = new ServerTabCodec(settings, modManager, modScanTab, dialogueSpawner, launcher, serverConfigurations, processWatcher, availableModsAndMaps); - var serversTab = InitializeFromFileWithCodec(codec, jsonPath, initializeDefault); - RegisterSaveToFileOnExit(serversTab, codec, jsonPath); - return serversTab; - } - - private T InitializeFromFileWithCodec(ICodec codec, string filePath, Func initializeDefault) { - _log.Info($"Loading {typeof(T).Name} from {filePath} using {codec.GetType().Name}({codec})..."); - return codec.DeserializeFile(filePath).Match( - None: initializeDefault, - Some: deserializationResult => Optional(deserializationResult.Result).IfNone(() => { - _log.Error( - $"Failed to deserialize saved {typeof(T).Name} data from {filePath} using {codec.GetType().Name}({codec}). Falling back to default.", - deserializationResult.Exception); - return initializeDefault(); - } - )); - } - - private void RegisterSaveToFileOnExit(T t, ICodec codec, string filePath) { - RegisterExitHandler(() => { - try { - _log.Info($"Saving {typeof(T).Name} to {filePath} using {codec.GetType().Name}({codec})..."); - codec.SerializeFile(filePath, t); - } - catch (Exception ex) { - _log.Error( - $"Failed to save configuration for {typeof(T).Name} to {filePath} using {codec.GetType().Name}({codec}).", - ex); - } - }); - } - - /// - /// Registers exit handlers for multiple shutdown scenarios to ensure reliable saving. - /// This should be called once during startup after all components are initialized. - /// - private void RegisterExitHandler(Action exitAction) { - Exit += (_, _) => ExecuteExitAction("Application.Exit", exitAction); - SessionEnding += (_, args) => ExecuteExitAction("SessionEnding", exitAction); - AppDomain.CurrentDomain.ProcessExit += (_, _) => ExecuteExitAction("ProcessExit", exitAction); - AppDomain.CurrentDomain.UnhandledException += (sender, args) => ExecuteExitAction("UnhandledException", exitAction); - Console.CancelKeyPress += (_, args) => ExecuteExitAction("Console.CancelKeyPress", exitAction); - } - - /// - /// Executes all registered exit actions exactly once, regardless of how many - /// exit events fire. This ensures data is saved but prevents duplicate saves. - /// - /// The event source triggering the exit actions (for logging). - /// - private void ExecuteExitAction(string source, Action exitAction) { - try { - exitAction(); - } - catch (Exception ex) { - _log.Error($"Exit action failed during {source}", ex); - } - } - } +using LanguageExt; +using log4net; +using System; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows; +using UnchainedLauncher.Core.Extensions; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Dlls; +using UnchainedLauncher.Core.Services.Installer; +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.GUI.ViewModels; +using UnchainedLauncher.GUI.ViewModels.Installer; +using UnchainedLauncher.GUI.ViewModels.Registry; +using UnchainedLauncher.GUI.ViewModels.ServersTab; +using UnchainedLauncher.GUI.Views; +using UnchainedLauncher.GUI.Views.Installer; +using UnchainedLauncher.UnrealModScanner.GUI.ViewModels; +using Application = System.Windows.Application; + +namespace UnchainedLauncher.GUI { + using static LanguageExt.Prelude; + + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application { + private static readonly ILog _log = LogManager.GetLogger(typeof(App)); + + protected override async void OnStartup(StartupEventArgs e) { + try { + base.OnStartup(e); + + AppDomain.CurrentDomain.UnhandledException += + (sender, args) => { + var ex = (Exception)args.ExceptionObject; + _log.Fatal("Unhandled exception", ex); + + File.WriteAllText("crash.log", ex.ToString()); + var currentDirectory = Directory.GetCurrentDirectory(); + MessageBox.Show($"An unhandled exception occurred. Please report this to a developer with {currentDirectory}\\crash.log ", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + }; + + // Capture the UI thread's SynchronizationContext for use throughout the application + UISynchronizationContext.Initialize(); + + var assembly = Assembly.GetExecutingAssembly(); + if (File.Exists("log4net.config")) { + log4net.Config.XmlConfigurator.Configure(new FileInfo("log4net.config")); + } + else if (File.Exists("Resources/log4net.config")) { + // for running in Visual Studio + log4net.Config.XmlConfigurator.Configure(new FileInfo("Resources/log4net.config")); + } + else { + using var configStream = + assembly.GetManifestResourceStream("UnchainedLauncher.GUI.Resources.log4net.config"); + if (configStream != null) { + log4net.Config.XmlConfigurator.Configure(configStream); + } + } + + // Init common dependencies + var githubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("UnchainedLauncher")); + var unchainedLauncherReleaseLocator = + new GithubReleaseLocator(githubClient, "Chiv2-Community", "UnchainedLauncher"); + + + var installationFinder = new Chivalry2InstallationFinder(); + var installer = new UnchainedLauncherInstaller(Shutdown); + + // figure out if we need to install by checking our current working directory + var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory()); + var needsInstallation = !installationFinder.IsValidInstallation(currentDirectory); + + var forceSkipInstallation = Environment.GetCommandLineArgs().ToList().Contains("--no-install"); + + if (forceSkipInstallation && needsInstallation) + _log.Info("Skipping installation"); + + var window = + needsInstallation && !forceSkipInstallation + ? InitializeInstallerWindow(installationFinder, installer, unchainedLauncherReleaseLocator) + : await InitializeMainWindow(installationFinder, installer, unchainedLauncherReleaseLocator); + + window?.Show(); + } + catch (Exception ex) { + Debug.WriteLine($"Error starting application: {ex.Message}"); + if (ex.StackTrace != null) + Debug.WriteLine(ex.StackTrace!); + + _log.Fatal("Unhandled exception", ex); + File.WriteAllText("crash.log", ex.ToString()); + var currentDirectory = Directory.GetCurrentDirectory(); + MessageBox.Show($"An unhandled exception occurred. Please report this to a developer with {currentDirectory}\\crash.log ", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + // Removed class-level command bindings; handled by Views.UnchainedWindow type. + + private Window? InitializeInstallerWindow(Chivalry2InstallationFinder installationFinder, + IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator) { + var installationSelectionVM = new InstallationSelectionPageViewModel(installationFinder); + var versionSelectionVM = new VersionSelectionPageViewModel(launcherReleaseLocator, new FileInfoVersionExtractor()); + var installationLogVM = new InstallerLogPageViewModel( + installer, + () => + from chiv2Installations in installationSelectionVM.Installations + where chiv2Installations.IsSelected + select chiv2Installations.Path + , + () => versionSelectionVM.SelectedVersion! + ); + + var installerPageViewModels = new ObservableCollection { + installationSelectionVM, versionSelectionVM, installationLogVM + }; + + var installerWindowVM = + new InstallerWindowViewModel(installerPageViewModels, installationSelectionVM.Installations); + return new InstallerWindow(installerWindowVM); + } + + private async Task InitializeMainWindow(IChivalry2InstallationFinder installationFinder, + IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator) { + var userDialogueSpawner = new MessageBoxSpawner(); + var registryWindowService = new RegistryWindowService(); + + var modRegistry = InitializeModRegistry(FilePaths.RegistryConfigPath); + var modManager = InitializeModManager(FilePaths.ModManagerConfigPath, modRegistry); + var dllManager = new DllManager( + Path.Combine(Directory.GetCurrentDirectory(), FilePaths.PluginDir), + FilePaths.DllManagerConfigPath, new HttpClient(), () => modManager.GetEnabledAndDependencyReleases().ToArray(), + () => modRegistry.ModRegistries.OfType() + .Select(registry => registry.DllDatabaseUrl).ToArray()); + + var registryWindowViewModel = new RegistryWindowVM(modRegistry, registryWindowService); + + var settingsViewModel = InitializeSettingsViewModel(installationFinder, installer, launcherReleaseLocator, registryWindowViewModel, registryWindowService, modManager, userDialogueSpawner); + var dllManagerViewModel = new DllManagerVM(dllManager, settingsViewModel, userDialogueSpawner); + await dllManagerViewModel.RefreshCommand.ExecuteAsync(null); + +#if DEBUG_FAKECHIVALRYLAUNCH + var officialProcessLauncher = new PowershellProcessLauncher( + "Official Chivalry 2" + ); + var unchainedProcessLauncher = new PowershellProcessLauncher( + "Unchained Chivalry 2" + ); +#else + var originalLauncherPath = File.Exists(FilePaths.OriginalLauncherPath) + ? FilePaths.OriginalLauncherPath + : FilePaths.LauncherPath; + var officialProcessLauncher = + new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), originalLauncherPath)); + var unchainedProcessLauncher = + new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), FilePaths.GameBinPath)); +#endif + + var noSigLaunchPreparer = NoSigPreparer.Create(modManager.PakDir, userDialogueSpawner); + var sigLaunchPreparer = SigPreparer.Create(modManager.PakDir, userDialogueSpawner); + + IChivalry2LaunchPreparer pluginInstaller = new DllLaunchPreparer(dllManager, userDialogueSpawner); + + IChivalry2LaunchPreparer modInstaller = new Chivalry2ModsInstaller( + modManager, userDialogueSpawner + ); + + var vanillaLauncher = new Chivalry2Launcher( + noSigLaunchPreparer.IgnoreOptions(), + officialProcessLauncher, + Directory.GetCurrentDirectory() + ); + + var unchainedLauncher = new UnchainedChivalry2Launcher( + pluginInstaller + .AndThen(modInstaller) + .Sub(sigLaunchPreparer), + 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) +#endif + ); + + var chivProcessMonitor = new ChivalryProcessWatcher(); + + var homeViewModel = new HomeVM( + settingsViewModel, + modManager, + vanillaLauncher, + unchainedLauncher, + userDialogueSpawner, + chivProcessMonitor); + var modListViewModel = new ModListVM(modManager, userDialogueSpawner); + + if (modManager.NeedsMetadataRefresh || !modManager.Mods.Any()) + await modListViewModel.RefreshModListCommand.ExecuteAsync(null); + + // Rebuild the arguments used to launch the application + var envArgs = Environment.GetCommandLineArgs().Skip(1).ToList(); + + // TODO: Replace this if/else chain with a real CLI + if (envArgs.Contains("--startvanilla")) { + homeViewModel.LaunchVanilla().Wait(); + return null; + } + + if (envArgs.Contains("--startunchained")) { + homeViewModel.LaunchUnchained().Wait(); + return null; + } + + var modScanTab = new ModScanTabVM(); + var helpVM = new HelpVM(settingsViewModel, installer, launcherReleaseLocator, modManager.PakDir, userDialogueSpawner, Shutdown); + var availableModsAndMaps = new AvailableModsAndMapsService(modManager, modScanTab); + + var serverConfigurationVMs = + InitializeServerConfigurations(FilePaths.ServerConfigurationsFilePath, modManager, modScanTab, availableModsAndMaps); + + var serversTabViewModel = InitializeServersTab( + FilePaths.ServersTabConfigurationPath, + settingsViewModel, + modManager, + modScanTab, + userDialogueSpawner, + unchainedLauncher, + serverConfigurationVMs, + chivProcessMonitor, + availableModsAndMaps + ); + + var mainWindowViewModel = new MainWindowVM( + homeViewModel, + modListViewModel, + settingsViewModel, + serversTabViewModel, + modScanTab, + helpVM, + dllManagerViewModel + ); + + return new MainWindow(mainWindowViewModel); + } + + private SettingsVM InitializeSettingsViewModel(IChivalry2InstallationFinder installationFinder, + IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator, + RegistryWindowVM registryWindowViewModel, RegistryWindowService registryWindowService, ModManager modManager, + MessageBoxSpawner userDialogueSpawner) { + var cliArgs = string.Join(" ", + Environment.GetCommandLineArgs() + .Skip(1) + .ToList() + .Select(ArgumentEscaper.Escape) + ); + + var settingsCodec = new SettingsCodec( + registryWindowViewModel, + registryWindowService, + installationFinder, + cliArgs + ); + + var settingsViewModel = InitializeFromFileWithCodec( + settingsCodec, + FilePaths.LauncherSettingsFilePath, + () => new SettingsVM( + registryWindowViewModel, + registryWindowService, + SettingsVM.DetectInstallationType(installationFinder), + true, + false, + "", + "https://servers.polehammer.net", + false, + SettingsVM.Version.IsPrerelease, + cliArgs + ) + ); + + RegisterSaveToFileOnExit(settingsViewModel, settingsCodec, FilePaths.LauncherSettingsFilePath); + return settingsViewModel; + } + + private AggregateModRegistry InitializeModRegistry(string jsonPath) { + AggregateModRegistry CreateDefaultModRegistry() => new AggregateModRegistry( + new GithubModRegistry("Chiv2-Community", "C2ModRegistry")); + + var loadedResult = + InitializeFromFileWithCodec(ModRegistryCodec.Instance, jsonPath, CreateDefaultModRegistry); + // Ensure that we've got an AggregateModRegistry. The constructor will handle it if we're wrapping + // another AggregateModRegistry, so no worries there. + var registry = new AggregateModRegistry(loadedResult); + + RegisterSaveToFileOnExit(registry, ModRegistryCodec.Instance, jsonPath); + return registry; + } + + private ModManager InitializeModManager(string jsonPath, IModRegistry registry) { + Func initializeDefaultModManager = () => + new ModManager( + registry, + new PakDir(FilePaths.PakDir, Enumerable.Empty()), + Enumerable.Empty() + ); + + var codec = new ModManagerCodec(registry); + var modManager = InitializeFromFileWithCodec(codec, jsonPath, initializeDefaultModManager); + + RegisterSaveToFileOnExit(modManager, codec, jsonPath); + return modManager; + } + + private ObservableCollection InitializeServerConfigurations(string jsonPath, + IModManager modManager, ModScanTabVM modScanTab, AvailableModsAndMapsService availableModsAndMapsVM) { + Func> initializeDefault = + () => new ObservableCollection(); + + var codec = new ServerConfigurationCodec(modManager, modScanTab, availableModsAndMapsVM); + var serverConfigurations = InitializeFromFileWithCodec(codec, jsonPath, initializeDefault); + + RegisterSaveToFileOnExit(serverConfigurations, codec, jsonPath); + Exit += (_, _) => serverConfigurations.ForEach(x => x.SaveINI()); + + return serverConfigurations; + } + + private ServersTabVM InitializeServersTab( + string jsonPath, + SettingsVM settings, + IModManager modManager, + ModScanTabVM modScanTab, + IUserDialogueSpawner dialogueSpawner, + IChivalry2Launcher launcher, + ObservableCollection serverConfigurations, + IChivalryProcessWatcher processWatcher, + AvailableModsAndMapsService availableModsAndMaps) { + Func initializeDefault = () => new ServersTabVM( + settings, + modManager, + modScanTab, + dialogueSpawner, + launcher, + serverConfigurations, + processWatcher, + availableModsAndMaps + ); + + var codec = new ServerTabCodec(settings, modManager, modScanTab, dialogueSpawner, launcher, serverConfigurations, processWatcher, availableModsAndMaps); + var serversTab = InitializeFromFileWithCodec(codec, jsonPath, initializeDefault); + RegisterSaveToFileOnExit(serversTab, codec, jsonPath); + return serversTab; + } + + private T InitializeFromFileWithCodec(ICodec codec, string filePath, Func initializeDefault) { + _log.Info($"Loading {typeof(T).Name} from {filePath} using {codec.GetType().Name}({codec})..."); + return codec.DeserializeFile(filePath).Match( + None: initializeDefault, + Some: deserializationResult => Optional(deserializationResult.Result).IfNone(() => { + _log.Error( + $"Failed to deserialize saved {typeof(T).Name} data from {filePath} using {codec.GetType().Name}({codec}). Falling back to default.", + deserializationResult.Exception); + return initializeDefault(); + } + )); + } + + private void RegisterSaveToFileOnExit(T t, ICodec codec, string filePath) { + RegisterExitHandler(() => { + try { + _log.Info($"Saving {typeof(T).Name} to {filePath} using {codec.GetType().Name}({codec})..."); + codec.SerializeFile(filePath, t); + } + catch (Exception ex) { + _log.Error( + $"Failed to save configuration for {typeof(T).Name} to {filePath} using {codec.GetType().Name}({codec}).", + ex); + } + }); + } + + /// + /// Registers exit handlers for multiple shutdown scenarios to ensure reliable saving. + /// This should be called once during startup after all components are initialized. + /// + private void RegisterExitHandler(Action exitAction) { + Exit += (_, _) => ExecuteExitAction("Application.Exit", exitAction); + SessionEnding += (_, args) => ExecuteExitAction("SessionEnding", exitAction); + AppDomain.CurrentDomain.ProcessExit += (_, _) => ExecuteExitAction("ProcessExit", exitAction); + AppDomain.CurrentDomain.UnhandledException += (sender, args) => ExecuteExitAction("UnhandledException", exitAction); + Console.CancelKeyPress += (_, args) => ExecuteExitAction("Console.CancelKeyPress", exitAction); + } + + /// + /// Executes all registered exit actions exactly once, regardless of how many + /// exit events fire. This ensures data is saved but prevents duplicate saves. + /// + /// The event source triggering the exit actions (for logging). + /// + private void ExecuteExitAction(string source, Action exitAction) { + try { + exitAction(); + } + catch (Exception ex) { + _log.Error($"Exit action failed during {source}", ex); + } + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.GUI/Converters/ModSortModeToStringConverter.cs b/UnchainedLauncher.GUI/Converters/ModSortModeToStringConverter.cs index 1982140b..e6926f4a 100644 --- a/UnchainedLauncher.GUI/Converters/ModSortModeToStringConverter.cs +++ b/UnchainedLauncher.GUI/Converters/ModSortModeToStringConverter.cs @@ -9,7 +9,7 @@ public class ModSortModeToStringConverter : IValueConverter { return value switch { ModSortMode.Alphabetical => "Alphabetical", ModSortMode.LatestReleaseDateFirst => "Latest New Releases First", - ModSortMode.NewestModsFirst => "Newest Mods First", + ModSortMode.NewestModsFirst => parameter as string == "Plugins" ? "Newest Plugins First" : "Newest Mods First", ModSortMode.EnabledFirst => "Enabled first", // Technically redundant, but it's here for completeness sake _ => value?.ToString() ?? "" }; diff --git a/UnchainedLauncher.GUI/JsonModels/LauncherSettings.cs b/UnchainedLauncher.GUI/JsonModels/LauncherSettings.cs index 92c6e430..d10851fc 100644 --- a/UnchainedLauncher.GUI/JsonModels/LauncherSettings.cs +++ b/UnchainedLauncher.GUI/JsonModels/LauncherSettings.cs @@ -12,5 +12,8 @@ public record LauncherSettings( [property: JsonPropertyName("use_light_theme")] bool? UseLightTheme, [property: JsonPropertyName("allow_unstable_plugin_releases")] bool? AllowUnstablePluginReleases, [property: JsonPropertyName("last_launch_version")] SemVersion? LastLaunchVersion - ); + ) { + public bool ShouldAllowUnstablePluginReleases(SemVersion runningVersion) => runningVersion.IsPrerelease + || (LastLaunchVersion?.WithoutMetadata() == runningVersion.WithoutMetadata() && AllowUnstablePluginReleases == true); + } } \ No newline at end of file diff --git a/UnchainedLauncher.GUI/Resources/Themes/Misc.xaml b/UnchainedLauncher.GUI/Resources/Themes/Misc.xaml index 3aa065d9..86484026 100644 --- a/UnchainedLauncher.GUI/Resources/Themes/Misc.xaml +++ b/UnchainedLauncher.GUI/Resources/Themes/Misc.xaml @@ -1,6 +1,37 @@  + + + + + + + + + - \ No newline at end of file + diff --git a/UnchainedLauncher.GUI/ViewModels/DllManagerVM.cs b/UnchainedLauncher.GUI/ViewModels/DllManagerVM.cs new file mode 100644 index 00000000..74a5d50c --- /dev/null +++ b/UnchainedLauncher.GUI/ViewModels/DllManagerVM.cs @@ -0,0 +1,262 @@ +using CommunityToolkit.Mvvm.Input; +using PropertyChanged; +using Semver; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Dlls; + +namespace UnchainedLauncher.GUI.ViewModels { + public interface IPluginSettings { + bool EnablePluginAutomaticUpdates { get; set; } + bool AllowUnstablePluginReleases { get; set; } + } + + [AddINotifyPropertyChangedInterface] + public partial class DllManagerVM { + private readonly DllManager manager; + private readonly IPluginSettings? _settings; + private readonly IUserDialogueSpawner? _dialogue; + public ObservableCollection Plugins { get; } = []; + private string? _searchTerm; + public string? SearchTerm { + get => _searchTerm; + set { + if (_searchTerm == value) return; + _searchTerm = value; + Populate(); + } + } + private ModSortMode _selectedSortMode = ModSortMode.EnabledFirst; + public ModSortMode SelectedSortMode { + get => _selectedSortMode; + set { + if (_selectedSortMode == value) return; + _selectedSortMode = value; + Populate(); + } + } + public IReadOnlyList SortModes { get; } = [ModSortMode.EnabledFirst, ModSortMode.Alphabetical, + ModSortMode.LatestReleaseDateFirst, ModSortMode.NewestModsFirst]; + private bool _allowUnstablePluginReleases; + private bool _enablePluginAutomaticUpdates = true; + public bool EnablePluginAutomaticUpdates { + get => _settings?.EnablePluginAutomaticUpdates ?? _enablePluginAutomaticUpdates; + set { + if (EnablePluginAutomaticUpdates == value) return; + _enablePluginAutomaticUpdates = value; + if (_settings != null) _settings.EnablePluginAutomaticUpdates = value; + } + } + public bool AllowUnstablePluginReleases { + get => _settings?.AllowUnstablePluginReleases ?? _allowUnstablePluginReleases; + set { + if (AllowUnstablePluginReleases == value) return; + _allowUnstablePluginReleases = value; + if (_settings != null) _settings.AllowUnstablePluginReleases = value; + Populate(); + } + } + + public DllManagerVM(DllManager manager, IPluginSettings? settings = null, IUserDialogueSpawner? dialogue = null) { + this.manager = manager; + _settings = settings; + _dialogue = dialogue; + manager.SelectionChanged += (_, _) => Populate(); + if (settings is INotifyPropertyChanged observable) + observable.PropertyChanged += (_, args) => { + if (args.PropertyName is nameof(IPluginSettings.AllowUnstablePluginReleases) or null or "") Populate(); + }; + Populate(); + } + + private void Populate() { + var selections = manager.EnabledSelections; + var ids = manager.Releases.Select(release => release.Id).Concat(selections.Keys).Distinct(StringComparer.OrdinalIgnoreCase); + IEnumerable query = ids + .Select(id => new DllPluginVM(manager, manager.Releases.Where(release => release.Id.Equals(id, StringComparison.OrdinalIgnoreCase)).ToArray(), + AllowUnstablePluginReleases, ReportError, id: id)) + .Where(plugin => plugin.AvailableVersions.Count != 0 || plugin.IsEnabled); + var term = (SearchTerm ?? "").Trim(); + if (term.Length != 0) + query = query.Where(plugin => new[] { plugin.Name, plugin.Organization + "/" + plugin.RepositoryName, plugin.Description, plugin.Authors } + .Any(text => text.Contains(term, StringComparison.OrdinalIgnoreCase))); + var ordered = SelectedSortMode switch { + ModSortMode.EnabledFirst => query.OrderByDescending(plugin => plugin.IsEnabled).ThenBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase), + ModSortMode.LatestReleaseDateFirst => query.OrderByDescending(plugin => plugin.LatestReleaseDate).ThenBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase), + ModSortMode.NewestModsFirst => query.OrderByDescending(plugin => plugin.FirstReleaseDate).ThenBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase), + _ => query.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase) + }; + var displayed = ordered.ThenBy(plugin => plugin.Organization + "/" + plugin.RepositoryName, + StringComparer.OrdinalIgnoreCase).ToArray(); + Plugins.Clear(); + foreach (var plugin in displayed) Plugins.Add(plugin); + } + + [RelayCommand] + public async Task Refresh() { + try { + await manager.RefreshAsync(); + } + catch (Exception ex) { ReportError($"Could not refresh plugin list (using cached metadata if available): {ex.Message}"); } + Populate(); + } + + [RelayCommand] + public async Task UpdatePlugins() { + try { + await manager.RefreshAsync(); + Populate(); + var displayed = Plugins.Select(plugin => plugin.Organization + "/" + plugin.RepositoryName).ToHashSet(StringComparer.OrdinalIgnoreCase); + var updates = manager.GetUpdateCandidates(server: null, allowUnstable: AllowUnstablePluginReleases) + .Where(update => displayed.Contains(update.Current.Id)).ToArray(); + if (updates.Length == 0) { + return; + } + var details = updates.Select(update => new DependencyUpdate(update.Latest.Info?.Name ?? update.Latest.Id, + update.Current.Version, update.Latest.Version, + update.Latest.GithubReleaseUrl, + update.Latest.Info?.Description ?? "Native plugin")).ToArray(); + if (_dialogue?.DisplayUpdateMessage("Update Plugins?", "Plugin updates available.", "Yes", "No", null, details) != UserDialogueChoice.Yes) { + return; + } + foreach (var update in updates) manager.SetEnabled(update.Latest, true); + } + catch (Exception ex) { ReportError($"Could not update plugins: {ex.Message}"); } + Populate(); + } + + private void ReportError(string message) { + _dialogue?.DisplayMessage(message); + } + + } + + public partial class DllPluginVM : INotifyPropertyChanged { + public event PropertyChangedEventHandler? PropertyChanged; + private readonly DllManager _manager; + private readonly string _id; + private readonly Action _error; + private readonly Action _openUrl; + private DllRow? _selectedVersion; + public string Organization { get; } + public string RepositoryName { get; } + private PluginInfo? Info => DisplayRelease?.Info; + private DllRelease? DisplayRelease => SelectedVersion?.Release ?? EnabledRelease ?? AvailableVersions.FirstOrDefault()?.Release; + public string Target => DisplayRelease?.Info?.Target ?? "shared"; + public string FileName => DisplayRelease?.EffectiveFileName ?? RepositoryName + ".dll"; + public string ReleaseTag => DisplayRelease?.Tag ?? DisplayRelease?.Version ?? ""; + public string ReleaseDate => DisplayRelease?.ReleaseDate?.ToString("u") ?? "Unknown"; + public string ReleaseNotes => DisplayRelease?.ReleaseNotesMarkdown ?? "No release notes."; + public string RepositoryUrl => $"https://github.com/{Organization}/{RepositoryName}"; + public string GithubReleaseUrl => DisplayRelease?.GithubReleaseUrl ?? RepositoryUrl; + public string Name => Info?.Name ?? RepositoryName; + public string Description => IsUnavailable ? $"Enabled version {EnabledVersion} is no longer registered. Disable this selection to continue; its DLL will not be loaded." + : Info?.Description ?? "Metadata unavailable for this legacy release."; + public string Authors => Info == null ? "" : string.Join(", ", Info.Authors); + public string IconUrl => Info?.IconUrl ?? "pack://application:,,,/UnchainedLauncher;component/assets/chiv2-unchained-logo.png"; + public IReadOnlyList ImageUrls => Info?.ImageUrls ?? []; + public string Dependencies { + get { + if (Info == null) return "None"; + var text = string.Join("\n", Info.RequiredMods.Select(dependency => $"Mod: {dependency.RepoUrl} {dependency.Version}") + .Concat(Info.RequiredPlugins.Select(dependency => $"Plugin: {dependency.RepoUrl} {dependency.Version}"))); + return text.Length == 0 ? "None" : text; + } + } + public IReadOnlyList AvailableVersions { get; } + public DateTimeOffset LatestReleaseDate => AvailableVersions.Select(row => row.Release.ReleaseDate ?? DateTimeOffset.MinValue) + .DefaultIfEmpty(EnabledRelease?.ReleaseDate ?? DateTimeOffset.MinValue).Max(); + public DateTimeOffset FirstReleaseDate { get; } + public DllRelease? EnabledRelease { get; private set; } + public string? EnabledVersion { get; private set; } + public bool IsEnabled => EnabledVersion != null; + public bool IsUnavailable => IsEnabled && EnabledRelease == null; + public string ButtonText => IsEnabled ? "Disable" : "Enable"; + public bool HasHiddenEnabledVersion => EnabledRelease != null && !AvailableVersions.Any(row => row.Release == EnabledRelease); + public DllRow? SelectedVersion { + get => _selectedVersion; + set { + if (value == _selectedVersion || value == null) return; + if (!AvailableVersions.Contains(value)) throw new InvalidOperationException("Unavailable DLL version."); + try { + _manager.SetEnabled(value.Release, true); + _selectedVersion = value; + EnabledRelease = value.Release; + EnabledVersion = value.Release.Version; + NotifySelectionChanged(); + } + catch (Exception ex) { _error($"Could not save DLL selection: {ex.Message}"); } + } + } + + public DllPluginVM(DllManager manager, IReadOnlyList releases, bool showPrereleases, Action error, + Action? openUrl = null, string? id = null) { + _manager = manager; + _error = error; + _openUrl = openUrl ?? (url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true })); + _id = id ?? releases[0].Id; + var parts = _id.Split('/', 2); + Organization = parts[0]; + RepositoryName = parts.Length == 2 ? parts[1] : parts[0]; + EnabledVersion = manager.EnabledSelections.GetValueOrDefault(_id); + EnabledRelease = releases.FirstOrDefault(manager.IsEnabled); + FirstReleaseDate = releases.Where(release => release.ReleaseDate.HasValue).Select(release => release.ReleaseDate!.Value) + .DefaultIfEmpty(DateTimeOffset.MinValue).Min(); + AvailableVersions = releases.Select(release => new DllRow(release)) + .Where(row => showPrereleases || !row.IsPrerelease) + .OrderByDescending(row => row.SemanticVersion, + SemVersion.PrecedenceComparer).ToArray(); + _selectedVersion = AvailableVersions.FirstOrDefault(row => row.Release == EnabledRelease); + } + + [RelayCommand] + public void ViewOnGithub() => OpenLink(RepositoryUrl); + + [RelayCommand] + public void OpenGithubRelease() => OpenLink(GithubReleaseUrl); + + private void OpenLink(string url) { + try { _openUrl(url); } + catch (Exception ex) { _error($"Could not open GitHub link: {ex.Message}"); } + } + + [RelayCommand] + public void EnableOrDisable() { + try { + if (IsEnabled) { + _manager.Disable(_id); + EnabledRelease = null; + EnabledVersion = null; + _selectedVersion = null; + NotifySelectionChanged(); + } + else if (AvailableVersions.Count != 0) SelectedVersion = AvailableVersions[0]; + } + catch (Exception ex) { _error($"Could not save DLL selection: {ex.Message}"); } + } + + private void NotifySelectionChanged() { + foreach (var property in new[] { nameof(SelectedVersion), nameof(EnabledRelease), nameof(EnabledVersion), nameof(IsUnavailable), nameof(IsEnabled), nameof(ButtonText), nameof(HasHiddenEnabledVersion), + nameof(Name), nameof(Description), nameof(Authors), nameof(Dependencies), nameof(IconUrl), nameof(ImageUrls), + nameof(Target), nameof(FileName), nameof(ReleaseTag), nameof(ReleaseDate), nameof(ReleaseNotes), nameof(GithubReleaseUrl) }) + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property)); + } + } + + public sealed record DllRow(DllRelease Release) { + public SemVersion SemanticVersion { get; } = SemVersion.Parse(Release.Version, SemVersionStyles.Strict); + public bool IsPrerelease => SemanticVersion.IsPrerelease; + public string Organization => Release.Organization; + public string Name => Release.Name; + public string Version => Release.Version; + public long Size => Release.Size; + public string Source => Release.DownloadUrl; + public string Sha256 => Release.Sha256; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.GUI/ViewModels/MainWindowVM.cs b/UnchainedLauncher.GUI/ViewModels/MainWindowVM.cs index 7a8ad4de..7e95f722 100644 --- a/UnchainedLauncher.GUI/ViewModels/MainWindowVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/MainWindowVM.cs @@ -11,19 +11,22 @@ public partial class MainWindowVM : INotifyPropertyChanged, IDisposable { public SettingsVM SettingsViewModel { get; } public ServersTabVM ServersTab { get; } public HelpVM HelpVM { get; } + public DllManagerVM? DllManagerViewModel { get; } public MainWindowVM(HomeVM launcherVM, ModListVM modListViewModel, SettingsVM settingsViewModel, ServersTabVM serversTab, ModScanTabVM modScanTabVM, - HelpVM helpVM) { + HelpVM helpVM, + DllManagerVM? dllManagerViewModel = null) { HomeVM = launcherVM; ModListViewModel = modListViewModel; SettingsViewModel = settingsViewModel; ServersTab = serversTab; ModScanTabVM = modScanTabVM; HelpVM = helpVM; + DllManagerViewModel = dllManagerViewModel; } public event PropertyChangedEventHandler? PropertyChanged; diff --git a/UnchainedLauncher.GUI/ViewModels/ModListVM.cs b/UnchainedLauncher.GUI/ViewModels/ModListVM.cs index b17e1259..500f9e92 100644 --- a/UnchainedLauncher.GUI/ViewModels/ModListVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/ModListVM.cs @@ -202,10 +202,4 @@ public void PopulateModsFromModManager() { } } - public enum ModSortMode { - EnabledFirst, - Alphabetical, - LatestReleaseDateFirst, - NewestModsFirst - } } \ No newline at end of file diff --git a/UnchainedLauncher.GUI/ViewModels/ModSortMode.cs b/UnchainedLauncher.GUI/ViewModels/ModSortMode.cs new file mode 100644 index 00000000..e5526649 --- /dev/null +++ b/UnchainedLauncher.GUI/ViewModels/ModSortMode.cs @@ -0,0 +1,8 @@ +namespace UnchainedLauncher.GUI.ViewModels { + public enum ModSortMode { + EnabledFirst, + Alphabetical, + LatestReleaseDateFirst, + NewestModsFirst + } +} \ No newline at end of file diff --git a/UnchainedLauncher.GUI/ViewModels/ModVM.cs b/UnchainedLauncher.GUI/ViewModels/ModVM.cs index 7a69950f..52cfb7db 100644 --- a/UnchainedLauncher.GUI/ViewModels/ModVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/ModVM.cs @@ -1,185 +1,178 @@ -using CommunityToolkit.Mvvm.Input; -using LanguageExt; -using LanguageExt.UnsafeValueAccess; -using log4net; -using Semver; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.Linq; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Services.Mods; -using UnchainedLauncher.UnrealModScanner.JsonModels; - -namespace UnchainedLauncher.GUI.ViewModels { - using static LanguageExt.Prelude; - - public partial class ModVM : INotifyPropertyChanged { - private static readonly ILog Logger = LogManager.GetLogger(nameof(ModVM)); - // A ModViewModel needs access to the mod manager so that it can enable/disable releases as they get set on the view. - private IModManager ModManager { get; } - public Mod Mod { get; } - - public VersionNameSort VersionNameSortKey => new VersionNameSort( - Optional(EnabledRelease).Map(x => x.Version).FirstOrDefault(), - Mod.LatestReleaseInfo.Name - ); - - public Release? EnabledRelease { get; set; } - - - public string Description { - get { - var description = Optional(EnabledRelease).Match( - None: Mod.LatestReleaseInfo.Description, - Some: x => x.Info.Description - ); - - var depenencies = - (EnabledRelease ?? Mod.LatestRelease) - .ToList() - .Bind(r => ModManager.GetAllDependenciesForRelease(r)); - - var depMessage = - !depenencies.Any() - ? "" - : depenencies.Fold( - "You must also enable the dependencies below:\n", - (aggMessage, dep) => aggMessage + $"- {dep.Info.Name} {dep.Version}\n" - ); - - return description + "\n\n" + depMessage; - } - } - - public string IconUrl => - EnabledRelease?.Info.NonEmptyIconUrl - ?? Mod.LatestReleaseInfo.NonEmptyIconUrl - ?? "pack://application:,,,/UnchainedLauncher;component/assets/chiv2-unchained-logo.png"; - - public AssetCollections? Manifest => EnabledRelease?.Manifest ?? Mod.LatestRelease.FirstOrDefault()?.Manifest; - - public bool HasMarkers => Manifest?.Markers?.Any() ?? false; - public bool HasBlueprints => Manifest?.Blueprints?.Any() ?? false; - public bool HasMaps => Manifest?.Maps?.Any() ?? false; - public bool HasReplacements => Manifest?.Replacements?.Any() ?? false; - public bool HasArbitrary => Manifest?.Arbitrary?.Any() ?? false; - - public bool HasContents => HasMarkers || HasBlueprints || HasMaps || HasReplacements || HasArbitrary; - - public bool HasDependencies => - (EnabledRelease ?? Mod.LatestRelease) - .ToList() - .Bind(r => ModManager.GetAllDependenciesForRelease(r)) - .Any(); - - public List Dependencies => - (EnabledRelease ?? Mod.LatestRelease) - .ToList() - .Bind(r => ModManager.GetAllDependenciesForRelease(r)) - .ToList(); - - public string ButtonText => - Optional(EnabledRelease).Match( - None: () => "Enable", - Some: _ => "Disable" - ); - - - public bool IsEnabled => EnabledRelease != null; - - public string EnabledVersion => - Optional(EnabledRelease).Match( - None: () => "none", - Some: x => x.Tag - ); - - public List AvailableVersions => Mod.Releases.Select(x => x.Tag).ToList(); - - public string GithubReleaseUrl => (EnabledRelease?.ReleaseUrl) ?? Mod.LatestReleaseInfo.RepoUrl; - - public ModVM(Mod mod, Option enabledRelease, IModManager modManager) { - EnabledRelease = enabledRelease.ValueUnsafe(); - - Mod = mod; - ModManager = modManager; - - PropertyChangedEventManager.AddHandler(this, (sender, e) => { - if (e.PropertyName == nameof(EnabledRelease)) { - UpdateCurrentlyEnabledVersion(EnabledRelease); - // Trigger dependent UI updates via re-evaluation (weaving should raise for EnabledRelease) - } - }, nameof(EnabledRelease)); - - Logger.Debug($"Initialized ModViewModel for {mod.LatestReleaseInfo.Name}. Currently enabled release: {EnabledVersion}"); - } - - public Option CheckForUpdate() { - return (Optional(EnabledRelease), Mod.LatestRelease) - .Sequence() - .Bind(x => UpdateCandidate.CreateIfNewer(x.Item1, x.Item2)); - } - - [RelayCommand] - private void EnableOrDisable() { - if (EnabledRelease == null) - EnabledRelease = Mod.Releases.First(); - else - EnabledRelease = null; - } - - [RelayCommand] - private void ViewOnGithub() { - var url = GithubReleaseUrl; - if (string.IsNullOrWhiteSpace(url)) - return; - - try { - Process.Start(new ProcessStartInfo { - FileName = url, - UseShellExecute = true - }); - } - catch (Exception e) { - Logger.Warn($"Failed to open URL: {url}", e); - } - } - - public bool UpdateCurrentlyEnabledVersion(Option newVersion) => - newVersion.Match( - None: () => ModManager.DisableMod(Mod), - Some: ModManager.EnableModRelease - ); - - [RelayCommand] - private void OpenGithubRelease() { - var url = GithubReleaseUrl; - if (string.IsNullOrWhiteSpace(url)) return; - try { - Process.Start(new ProcessStartInfo { - FileName = url, - UseShellExecute = true - }); - } - catch (Exception ex) { - Logger.Warn($"Failed to open URL: {url}", ex); - } - } - } - - public record VersionNameSort(SemVersion? Version, string Name) : IComparable { - public int CompareTo(VersionNameSort? other) { - if (other == null) - return 1; - - if (Version == null) - return 1; - - if (other.Version == null) - return -1; - - return Name.CompareTo(other.Name); - } - } +using CommunityToolkit.Mvvm.Input; +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using log4net; +using Semver; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.UnrealModScanner.JsonModels; + +namespace UnchainedLauncher.GUI.ViewModels { + using static LanguageExt.Prelude; + + public partial class ModVM : INotifyPropertyChanged { + private static readonly ILog Logger = LogManager.GetLogger(nameof(ModVM)); + // A ModViewModel needs access to the mod manager so that it can enable/disable releases as they get set on the view. + private IModManager ModManager { get; } + public Mod Mod { get; } + + public VersionNameSort VersionNameSortKey => new VersionNameSort( + Optional(EnabledRelease).Map(x => x.Version).FirstOrDefault(), + Mod.LatestReleaseInfo.Name + ); + + public Release? EnabledRelease { get; set; } + + + public string Description { + get { + var description = Optional(EnabledRelease).Match( + None: Mod.LatestReleaseInfo.Description, + Some: x => x.Info.Description + ); + + var depenencies = + (EnabledRelease ?? Mod.LatestRelease) + .ToList() + .Bind(r => ModManager.GetAllDependenciesForRelease(r)); + + var depMessage = + !depenencies.Any() + ? "" + : depenencies.Fold( + "You must also enable the dependencies below:\n", + (aggMessage, dep) => aggMessage + $"- {dep.Info.Name} {dep.Version}\n" + ); + + return description + "\n\n" + depMessage; + } + } + + public string IconUrl => + EnabledRelease?.Info.NonEmptyIconUrl + ?? Mod.LatestReleaseInfo.NonEmptyIconUrl + ?? "pack://application:,,,/UnchainedLauncher;component/assets/chiv2-unchained-logo.png"; + + public AssetCollections? Manifest => EnabledRelease?.Manifest ?? Mod.LatestRelease.FirstOrDefault()?.Manifest; + + public bool HasMarkers => Manifest?.Markers?.Any() ?? false; + public bool HasBlueprints => Manifest?.Blueprints?.Any() ?? false; + public bool HasMaps => Manifest?.Maps?.Any() ?? false; + public bool HasReplacements => Manifest?.Replacements?.Any() ?? false; + public bool HasArbitrary => Manifest?.Arbitrary?.Any() ?? false; + + public bool HasContents => HasMarkers || HasBlueprints || HasMaps || HasReplacements || HasArbitrary; + + public bool HasDependencies => + ((EnabledRelease ?? Mod.LatestRelease.FirstOrDefault())?.Info.EffectivePluginDependencies.Count ?? 0) > 0 || + (EnabledRelease ?? Mod.LatestRelease) + .ToList() + .Bind(r => ModManager.GetAllDependenciesForRelease(r)) + .Any(); + + public List Dependencies => + (EnabledRelease ?? Mod.LatestRelease) + .ToList() + .Bind(r => ModManager.GetAllDependenciesForRelease(r)) + .ToList(); + + public List PluginDependencies => + (EnabledRelease ?? Mod.LatestRelease.FirstOrDefault())?.Info.EffectivePluginDependencies ?? []; + + public string ButtonText => + Optional(EnabledRelease).Match( + None: () => "Enable", + Some: _ => "Disable" + ); + + + public bool IsEnabled => EnabledRelease != null; + + public string EnabledVersion => + Optional(EnabledRelease).Match( + None: () => "none", + Some: x => x.Tag + ); + + public List AvailableVersions => Mod.Releases.Select(x => x.Tag).ToList(); + + public string GithubReleaseUrl => (EnabledRelease?.ReleaseUrl) ?? Mod.LatestReleaseInfo.RepoUrl; + + public ModVM(Mod mod, Option enabledRelease, IModManager modManager) { + EnabledRelease = enabledRelease.ValueUnsafe(); + + Mod = mod; + ModManager = modManager; + + PropertyChangedEventManager.AddHandler(this, (sender, e) => { + if (e.PropertyName == nameof(EnabledRelease)) { + UpdateCurrentlyEnabledVersion(EnabledRelease); + // Trigger dependent UI updates via re-evaluation (weaving should raise for EnabledRelease) + } + }, nameof(EnabledRelease)); + + Logger.Debug($"Initialized ModViewModel for {mod.LatestReleaseInfo.Name}. Currently enabled release: {EnabledVersion}"); + } + + public Option CheckForUpdate() { + return (Optional(EnabledRelease), Mod.LatestRelease) + .Sequence() + .Bind(x => UpdateCandidate.CreateIfNewer(x.Item1, x.Item2)); + } + + [RelayCommand] + private void EnableOrDisable() { + if (EnabledRelease == null) + EnabledRelease = Mod.Releases.First(); + else + EnabledRelease = null; + } + + [RelayCommand] + private void ViewOnGithub() => OpenLink(GithubReleaseUrl); + + private static void OpenLink(string? url) { + if (string.IsNullOrWhiteSpace(url)) + return; + + try { + Process.Start(new ProcessStartInfo { + FileName = url, + UseShellExecute = true + }); + } + catch (Exception e) { + Logger.Warn($"Failed to open URL: {url}", e); + } + } + + public bool UpdateCurrentlyEnabledVersion(Option newVersion) => + newVersion.Match( + None: () => ModManager.DisableMod(Mod), + Some: ModManager.EnableModRelease + ); + + [RelayCommand] + private void OpenGithubRelease() => OpenLink(GithubReleaseUrl); + } + + public record VersionNameSort(SemVersion? Version, string Name) : IComparable { + public int CompareTo(VersionNameSort? other) { + if (other == null) + return 1; + + if (Version == null) + return 1; + + if (other.Version == null) + return -1; + + return Name.CompareTo(other.Name); + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.GUI/ViewModels/Registry/LocalModRegistryDetailsVM.cs b/UnchainedLauncher.GUI/ViewModels/Registry/LocalModRegistryDetailsVM.cs index d50a9097..1800c692 100644 --- a/UnchainedLauncher.GUI/ViewModels/Registry/LocalModRegistryDetailsVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/Registry/LocalModRegistryDetailsVM.cs @@ -8,11 +8,9 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Windows; using UnchainedLauncher.Core.JsonModels.ModMetadata; using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Utilities; using UnchainedLauncher.GUI.Services; using UnchainedLauncher.UnrealModScanner.JsonModels; @@ -175,104 +173,4 @@ public void Edit() { } } - /// - /// Duplicates Release, but it's modifiable - /// - [AddINotifyPropertyChangedInterface] - public partial class RegistryReleaseFormVM { - // Release stuff - public string Tag { get; set; } - public string ReleaseHash { get; set; } - private string PakFileName => Path.GetFileName(PakFilePath) ?? "Unknown; invalid path"; - private string? _pakFilePath; - [DoNotCheckEquality] - public string? PakFilePath { - get => _pakFilePath; - set { - _pakFilePath = value; - if (value != null) { - // this will only error if the path is invalid, which it won't be because the - // view will validate it before binding - ReleaseHash = FileHelpers.Sha512(value).IfLeft(e => e.Message); - } - } - } - public DateTime ReleaseDate { get; set; } - public AssetCollections Manifest { get; } - - // Manifest stuff - public string RepoUrl { - get => $"{Organization}/{Regex.Replace(Name, "\\s", "-")}"; - } - public string Organization { get; set; } - public string Name { get; set; } - public string Description { get; set; } - public string? HomePage { get; set; } - public string? IconUrl { get; set; } - public List ImageUrls { get; set; } - - public List Authors { get; set; } - public List Dependencies { get; set; } - - public string ReleaseNotesMarkdown { get; set; } - - public string LastSubmitComplaint { get; set; } = ""; - - [RelayCommand] - public void Submit() { - if (PakFilePath == null) { - LastSubmitComplaint = "Please add a pak file"; - return; - } - OnSubmit?.Invoke(); - } - public event Action? OnSubmit; - public RegistryReleaseFormVM(Release release, string? pakFilePath) { - PakFilePath = pakFilePath; - // Release stuff - Tag = release.Tag; - ReleaseHash = release.ReleaseHash; - ReleaseDate = release.ReleaseDate; - - // Manifest stuff - Manifest = release.Manifest; - Organization = release.Info.Organization; - Name = release.Info.Name; - Description = release.Info.Description; - HomePage = release.Info.HomePage; - IconUrl = release.Info.IconUrl; - ImageUrls = release.Info.ImageUrls; - Authors = release.Info.Authors; - Dependencies = release.Info.Dependencies; - ReleaseNotesMarkdown = release.ReleaseNotesMarkdown ?? ""; - } - - public (Release release, string pakPath) ToRelease() { - ModInfo m = new ModInfo( - RepoUrl, - Name, - Description, - HomePage, - IconUrl, - ImageUrls, - Authors, - Dependencies - ); - - if (PakFilePath == null) { - throw new InvalidOperationException("Pak file path is not set"); - } - - Release r = new Release( - Tag, - ReleaseHash, - PakFileName, - ReleaseDate, - m, - Manifest, - ReleaseNotesMarkdown); - - return (r, PakFilePath); - } - } } \ No newline at end of file diff --git a/UnchainedLauncher.GUI/ViewModels/Registry/RegistryReleaseFormVM.cs b/UnchainedLauncher.GUI/ViewModels/Registry/RegistryReleaseFormVM.cs new file mode 100644 index 00000000..e0caf2ba --- /dev/null +++ b/UnchainedLauncher.GUI/ViewModels/Registry/RegistryReleaseFormVM.cs @@ -0,0 +1,89 @@ +using CommunityToolkit.Mvvm.Input; +using PropertyChanged; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; + +namespace UnchainedLauncher.GUI.ViewModels.Registry { + /// Duplicates Release, but it's modifiable. + [AddINotifyPropertyChangedInterface] + public partial class RegistryReleaseFormVM { + private readonly ModInfo _originalInfo; + public string Tag { get; set; } + public string ReleaseHash { get; set; } + private string PakFileName => Path.GetFileName(PakFilePath) ?? "Unknown; invalid path"; + private string? _pakFilePath; + [DoNotCheckEquality] + public string? PakFilePath { + get => _pakFilePath; + set { + _pakFilePath = value; + if (value != null) { + // The view validates the path before binding. + ReleaseHash = FileHelpers.Sha512(value).IfLeft(e => e.Message); + } + } + } + public DateTime ReleaseDate { get; set; } + public AssetCollections Manifest { get; } + public string RepoUrl => $"{Organization}/{Regex.Replace(Name, "\\s", "-")}"; + public string Organization { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string? HomePage { get; set; } + public string? IconUrl { get; set; } + public List ImageUrls { get; set; } + public List Authors { get; set; } + public List Dependencies { get; set; } + public string ReleaseNotesMarkdown { get; set; } + public string LastSubmitComplaint { get; set; } = ""; + + [RelayCommand] + public void Submit() { + if (PakFilePath == null) { + LastSubmitComplaint = "Please add a pak file"; + return; + } + OnSubmit?.Invoke(); + } + public event Action? OnSubmit; + + public RegistryReleaseFormVM(Release release, string? pakFilePath) { + _originalInfo = release.Info; + PakFilePath = pakFilePath; + Tag = release.Tag; + ReleaseHash = release.ReleaseHash; + ReleaseDate = release.ReleaseDate; + Manifest = release.Manifest; + Organization = release.Info.Organization; + Name = release.Info.Name; + Description = release.Info.Description; + HomePage = release.Info.HomePage; + IconUrl = release.Info.IconUrl; + ImageUrls = release.Info.ImageUrls; + Authors = release.Info.Authors; + Dependencies = release.Info.EffectiveModDependencies; + ReleaseNotesMarkdown = release.ReleaseNotesMarkdown ?? ""; + } + + public (Release release, string pakPath) ToRelease() { + var info = _originalInfo with { + RepoUrl = RepoUrl, + Name = Name, + Description = Description, + HomePage = HomePage, + IconUrl = IconUrl, + ImageUrls = ImageUrls, + Authors = Authors, + Dependencies = Dependencies, + ModDependencies = null + }; + if (PakFilePath == null) throw new InvalidOperationException("Pak file path is not set"); + return (new Release(Tag, ReleaseHash, PakFileName, ReleaseDate, info, Manifest, ReleaseNotesMarkdown), PakFilePath); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.GUI/ViewModels/SettingsVM.cs b/UnchainedLauncher.GUI/ViewModels/SettingsVM.cs index 0333aa21..4a57f3b6 100644 --- a/UnchainedLauncher.GUI/ViewModels/SettingsVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/SettingsVM.cs @@ -56,16 +56,14 @@ string cliArgs settings?.AdditionalModActors ?? "", settings?.ServerBrowserBackend ?? "https://servers.polehammer.net", settings?.UseLightTheme ?? false, - settings?.LastLaunchVersion?.WithoutMetadata() == SettingsVM.Version.WithoutMetadata() - ? (settings?.AllowUnstablePluginReleases ?? SettingsVM.Version.IsPrerelease) - : SettingsVM.Version.IsPrerelease, + settings?.ShouldAllowUnstablePluginReleases(SettingsVM.Version) ?? SettingsVM.Version.IsPrerelease, cliArgs ); } } [AddINotifyPropertyChangedInterface] - public partial class SettingsVM { + public partial class SettingsVM : IPluginSettings { private static readonly ILog Logger = LogManager.GetLogger(nameof(SettingsVM)); public static readonly SemVersion Version = SemVersion.Parse(Assembly.GetExecutingAssembly().GetCustomAttribute()!.InformationalVersion, SemVersionStyles.Any); diff --git a/UnchainedLauncher.GUI/Views/MainWindow.xaml b/UnchainedLauncher.GUI/Views/MainWindow.xaml index 2c14bb51..e198ec0e 100644 --- a/UnchainedLauncher.GUI/Views/MainWindow.xaml +++ b/UnchainedLauncher.GUI/Views/MainWindow.xaml @@ -4,10 +4,12 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:UnchainedLauncher.GUI.Views" + xmlns:controls="clr-namespace:UnchainedLauncher.GUI.Views.Controls" xmlns:mods="clr-namespace:UnchainedLauncher.GUI.Views.Mods" xmlns:servers="clr-namespace:UnchainedLauncher.GUI.Views.Servers" xmlns:instances="clr-namespace:UnchainedLauncher.GUI.Views.DesignInstances" xmlns:scanner="clr-namespace:UnchainedLauncher.UnrealModScanner.GUI.Views;assembly=UnchainedLauncher.UnrealModScanner.GUI" + xmlns:behaviors="clr-namespace:UnchainedLauncher.GUI.Behaviors" mc:Ignorable="d" d:DataContext="{d:DesignInstance instances:MainWindowDesignVM}" x:Class="UnchainedLauncher.GUI.Views.MainWindow" @@ -157,6 +159,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Margin="0,0,8,0" Style="{StaticResource Button.EnableOrDisable}" /> + ToolTip="Select version" + VerticalAlignment="Stretch"> + + + + + + + + + @@ -234,21 +200,40 @@ IsExpanded="False" Visibility="{Binding HasDependencies, Converter={StaticResource BoolToVisibilityConverter}}"> + - - + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/UnchainedLauncher.GUI/Views/Settings.xaml b/UnchainedLauncher.GUI/Views/Settings.xaml index f5b5e8f2..da483575 100644 --- a/UnchainedLauncher.GUI/Views/Settings.xaml +++ b/UnchainedLauncher.GUI/Views/Settings.xaml @@ -27,13 +27,6 @@ ItemsSource="{Binding AllInstallationTypes}" /> - -