From c8bd89504e6f4a789456882b4ca6c97b1e8c577e Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 12 Oct 2026 20:22:34 -0500 Subject: [PATCH 01/34] Add registry-validated DLL loading and Plugin Manager --- README.md | 80 ++ .../Services/Dlls/DependencyTypesTests.cs | 43 ++ .../Services/Dlls/DllManagerTests.cs | 696 ++++++++++++++++++ .../JsonModels/ModMetadata/V4.cs | 10 +- .../Services/Dlls/DependencyValidator.cs | 22 + .../Services/Dlls/DllManager.cs | 338 +++++++++ .../Services/Dlls/DllRelease.cs | 130 ++++ .../Services/Mods/IModManager.cs | 4 +- .../Services/Mods/ModManagerCodec.cs | 6 +- .../LaunchPreparers/Chivalry2ModsInstaller.cs | 10 +- .../LaunchPreparers/DllLaunchPreparer.cs | 33 + .../Chivalry/UnchainedChivalry2Launcher.cs | 8 +- .../Services/Processes/DllInjector.cs | 61 +- .../Services/Processes/IProcessInjector.cs | 4 +- UnchainedLauncher.Core/Utilities/FilePaths.cs | 3 +- .../DllManagerVMTests.cs | 416 +++++++++++ .../PluginReleaseDefaultsTests.cs | 25 + .../ReleaseBadgeTests.cs | 40 + .../UnchainedLauncher.GUI.Tests.Unit.csproj | 23 + UnchainedLauncher.GUI/App.xaml.cs | 33 +- .../ModSortModeToStringConverter.cs | 4 +- .../JsonModels/LauncherSettings.cs | 7 +- .../Resources/Themes/Misc.xaml | 39 +- .../ViewModels/DllManagerVM.cs | 260 +++++++ .../ViewModels/MainWindowVM.cs | 7 +- UnchainedLauncher.GUI/ViewModels/ModListVM.cs | 8 +- .../ViewModels/ModSortMode.cs | 8 + UnchainedLauncher.GUI/ViewModels/ModVM.cs | 6 +- .../Registry/LocalModRegistryDetailsVM.cs | 4 +- .../ViewModels/SettingsVM.cs | 8 +- UnchainedLauncher.GUI/Views/MainWindow.xaml | 158 +++- UnchainedLauncher.GUI/Views/Mods/Mod.xaml | 72 +- UnchainedLauncher.GUI/Views/Settings.xaml | 7 - ...y-package-metadata-and-target-filtering.md | 166 +++++ ...er-links-and-prerelease-startup-default.md | 99 +++ ...-manager-workflow-and-update-boundaries.md | 113 +++ annals/README.md | 9 + 37 files changed, 2843 insertions(+), 117 deletions(-) create mode 100644 UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DependencyTypesTests.cs create mode 100644 UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs create mode 100644 UnchainedLauncher.Core/Services/Dlls/DependencyValidator.cs create mode 100644 UnchainedLauncher.Core/Services/Dlls/DllManager.cs create mode 100644 UnchainedLauncher.Core/Services/Dlls/DllRelease.cs create mode 100644 UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/DllLaunchPreparer.cs create mode 100644 UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs create mode 100644 UnchainedLauncher.GUI.Tests.Unit/PluginReleaseDefaultsTests.cs create mode 100644 UnchainedLauncher.GUI.Tests.Unit/ReleaseBadgeTests.cs create mode 100644 UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj create mode 100644 UnchainedLauncher.GUI/ViewModels/DllManagerVM.cs create mode 100644 UnchainedLauncher.GUI/ViewModels/ModSortMode.cs create mode 100644 annals/2026-10-12-dll-registry-package-metadata-and-target-filtering.md create mode 100644 annals/2026-10-12-plugin-manager-links-and-prerelease-startup-default.md create mode 100644 annals/2026-10-12-plugin-manager-workflow-and-update-boundaries.md create mode 100644 annals/README.md diff --git a/README.md b/README.md index 35eec301..92717597 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Check out [the wiki](https://unchained.wiki) for more information about modding - **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. - **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 @@ -48,6 +49,85 @@ Join the [Chivalry 2 Unchained community on Discord](https://discord.gg/chiv2unc 4. Put the downloaded Unchained `Chivalry2Launcher.exe` in place of the old one. 5. Launch the game normally (either from your steam library or from Epic Games Store). +## Plugin Manager + +UnchainedPlugin is enabled by default on first use. Each plugin has one card with +a version dropdown, like the Mod Manager. Click **Enable** to use its newest +visible version, or choose a version from the dropdown to enable that version. +Click **Disable** to stop loading it. Only one version per plugin can be enabled. +Click a plugin's name to view its source repository on GitHub. Expand **Release +notes** and click **View original release notes on GitHub** to open the displayed +version's release page, including its original notes and downloadable assets. +**Refresh Plugin List** retrieves releases without changing your selected versions. +Search matches plugin names, repositories, descriptions, and authors. Sort uses +the same enabled-first, alphabetical, latest-release, and newest-package modes +as Mod Manager. **Update Plugins** checks enabled plugins in the current search +results and asks before selecting newer registered versions; it does not enable +disabled plugins or download DLLs immediately. Downloads follow your settings +at the next Unchained launch. +The original **Enable Automatic Plugin Updates and Downloads** and **Enable +Unstable Plugin Releases** controls are at the bottom of Plugin Manager and use the same +saved settings as before. When automatic updates are enabled, Unchained launch +refreshes the registry and offers newer versions of enabled plugins for confirmation. +Declining keeps the selected version; disabled plugins are not enabled automatically. +Unstable releases are eligible for update offers only when their control is checked. +With automatic updates/downloads disabled, launch uses only installed, validated +DLLs; missing files cancel launch rather than downloading them. +Prereleases have a badge and are hidden unless **Enable Unstable Plugin Releases** is checked. +Prerelease launcher builds start with this control checked, even if it was saved +as unchecked. You can uncheck it for the current session; stable builds retain +the existing saved preference for the same launcher version. +Hiding a prerelease does not disable it; use **Disable** to stop loading it. +Plugin Manager uses the GitHub registries configured in the existing registry settings. +After adding or removing a registry, click **Refresh Plugin List** before launching. +Each registry may publish plugins under `dll_db`; mod-only registries are supported. +Plugin Manager reads `dll_db/plugin_list_index.txt` and each listed +`dll_db/packages//.json`, matching C2ModRegistry's +current format and zero-infra-mod-registry's `prepare-v1` plugin schema. +The package list is authoritative: the legacy `dll_db/index.json` is read only +when the package list does not exist. A removed release is not restored from a +stale legacy index. Existing selections remain pinned when registry metadata is +migrated to the package format. Previously approved historical releases may have +null metadata, dates, or filenames; these are supported inside metadata-backed +packages and retain SHA-256/size checks. +Matching duplicate releases are combined; conflicting records cancel refresh. +Cached approvals are usable offline only while the configured registry sources match. +Local pak directories do not approve DLLs. +Cards show the name, description, authors, target, asset filename, release date, +tag, notes and dependencies from the release's +`plugin.json`. Dependencies must be explicitly enabled at a compatible registered +version; the launcher does not enable them automatically. Missing or incompatible +dependencies cancel launch. DLL dependencies are injected before the plugins using them. +`target` is `client`, `server`, or `shared` (the default when omitted). +Client/server plugins are downloaded and injected only for their declared side; +shared plugins apply to both. Enabled versions stay selected when launching the +other side, but do not load there. A dependency must be enabled and applicable +to the same launch side; otherwise launch is cancelled before DLL downloads. +Pak dependency repository names can contain dots, while plugin repository names +follow the stricter plugin schema. + +Both `mod.json` and `plugin.json` can declare `mod_dependencies` (pak mods) and +`plugin_dependencies` (DLL plugins), using `repo_url` and `version` for each entry. +Existing mods' `dependencies` are treated as `mod_dependencies`. Plugin dependencies +must be explicitly enabled in Plugin Manager. Mods requiring DLL plugins need +Unchained launch; the clientside-only launcher cannot load their plugins. + +At Unchained launch, when automatic downloads are enabled, missing enabled DLLs are downloaded from the separate DLL +database in your configured registries. Their size and SHA-256 are checked before installation +and again immediately before injection. Validation failure cancels launch. +Loose DLLs in `TBL\Binaries\Win64\Plugins` are ignored; existing files are not +automatically trusted or deleted. Managed downloads live under `Plugins\Managed` +and use the registered `dll_file_name` (or `.dll` for historical +records without a filename). +Selections and the last valid index are saved under `.mod_cache` so previously +installed, validated releases can be used offline. + +If a managed DLL fails validation, remove that release's file under +`Plugins\Managed` and launch again to download it afresh. Do not bypass the check. +Only enable publishers you trust: integrity validation does not sandbox native code +or establish that a DLL is harmless. First use needs network access and the DLL +index must be deployed to the registry's `main` branch before this launcher is used. + ## License This project is licensed under the terms of the [LICENSE](LICENSE) file. 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..91734aa3 --- /dev/null +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DependencyTypesTests.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Dlls; +using Xunit; + +namespace UnchainedLauncher.Core.Tests.Unit.Services.Dlls { + public sealed class DependencyTypesTests { + [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])); + } + } +} 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..c0d3fee5 --- /dev/null +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs @@ -0,0 +1,696 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using UnchainedLauncher.Core.Services.Dlls; +using Xunit; + +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 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.DatabaseUrl, 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.DatabaseUrl]); + Assert.Equal(2, restarted.Releases.Count); + Assert.True(restarted.IsEnabled(registered)); + } + + [Fact] + public async Task RegistryChangesInvalidateCachedAndLiveApprovals() { + Index(Release()); + IReadOnlyList sources = [DllManager.DatabaseUrl]; + 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.DatabaseUrl]; + 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.DatabaseUrl, 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.DatabaseUrl, 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.PackageListUrl] = string.Join("\n", releases.Select(release => release.Id).Distinct()); + foreach (var group in releases.GroupBy(release => release.Id)) + _handler.Responses[$"{DllManager.DatabaseUrl}/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.IndexUrl] = "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.PackageListUrl] = ""; + 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); + 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(); } + // Selection is per call, not the side of the most recent preparation. + 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()); + } + + [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"), Release(version: "3.0.0-RC1")); + 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" : "2.0.0"; + Assert.Equal(expectedVersion, Assert.Single(dialogue.Updates).LatestVersion); + 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; + } + + [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 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.Empty(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + Assert.Empty(Directory.GetFiles(PluginPath, "*.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; } + 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.PackageListUrl) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { RequestMessage = request }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + RequestMessage = request, + Content = request.RequestUri!.AbsoluteUri == DllManager.IndexUrl + ? new StringContent(Index) : new ByteArrayContent(Binary) + }); + } + } + } +} diff --git a/UnchainedLauncher.Core/JsonModels/ModMetadata/V4.cs b/UnchainedLauncher.Core/JsonModels/ModMetadata/V4.cs index 40d93a00..5664b98e 100644 --- a/UnchainedLauncher.Core/JsonModels/ModMetadata/V4.cs +++ b/UnchainedLauncher.Core/JsonModels/ModMetadata/V4.cs @@ -52,11 +52,17 @@ 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]; } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Dlls/DependencyValidator.cs b/UnchainedLauncher.Core/Services/Dlls/DependencyValidator.cs new file mode 100644 index 00000000..7fd8de74 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Dlls/DependencyValidator.cs @@ -0,0 +1,22 @@ +using UnchainedLauncher.Core.JsonModels.ModMetadata; + +namespace UnchainedLauncher.Core.Services.Dlls { + public static class DependencyValidator { + public static void Validate(IReadOnlyList mods, IReadOnlyList plugins) { + foreach (var mod in mods) { + foreach (var dependency in mod.Info.EffectivePluginDependencies) { + if (!plugins.Any(plugin => dependency.RepoUrl.Equals($"https://github.com/{plugin.Id}", StringComparison.OrdinalIgnoreCase) + && PluginInfo.MatchesVersion(plugin.Version, dependency.Version))) + throw new InvalidOperationException($"Enable a compatible registered plugin for {mod.Info.Name}: {dependency.RepoUrl} {dependency.Version}"); + } + } + foreach (var plugin in plugins) { + foreach (var dependency in plugin.Info?.RequiredMods ?? []) { + if (!mods.Any(mod => dependency.RepoUrl.Equals(mod.Info.RepoUrl, StringComparison.OrdinalIgnoreCase) + && mod.Version != null && PluginInfo.MatchesVersion(mod.Version.ToString(), dependency.Version))) + throw new InvalidOperationException($"Enable a compatible mod for {plugin.Id}: {dependency.RepoUrl} {dependency.Version}"); + } + } + } + } +} diff --git a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs new file mode 100644 index 00000000..ae31270a --- /dev/null +++ b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs @@ -0,0 +1,338 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Semver; +using System.Net; +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 IndexUrl = "https://raw.githubusercontent.com/Chiv2-Community/C2ModRegistry/refs/heads/main/dll_db/index.json"; + public const string DatabaseUrl = "https://raw.githubusercontent.com/Chiv2-Community/C2ModRegistry/refs/heads/main/dll_db"; + public const string PackageListUrl = DatabaseUrl + "/plugin_list_index.txt"; + public const string DefaultPluginId = "Chiv2-Community/UnchainedPlugin"; + private readonly HttpClient _http; + private readonly string _root; + 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) { + _registryUrls = registryUrls ?? (() => [DatabaseUrl]); + _modReleases = modReleases ?? (() => []); + _root = Path.GetFullPath(Path.Combine(pluginDirectory, "Managed")); + _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."); + 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 json = JsonSerializer.Serialize(merged.Values.ToArray()); + var releases = ParseIndex(json); + 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 ParseIndex(JsonSerializer.Serialize(data.Releases.Select(record => { + if (record == null) throw new InvalidDataException("Null plugin release."); + return record.ToDllRelease(parts[0], parts[1]); + }))); + } + + 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), + Comparer.Create((a, b) => a.ComparePrecedenceTo(b))).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 void SetEnabled(DllRelease release, bool enabled) { + lock (_gate) { + EnsureCurrentSources(); + if (!Releases.Contains(release)) throw new InvalidOperationException("Unregistered DLL."); + var previous = _enabled.GetValueOrDefault(release.Id); + if (enabled) _enabled[release.Id] = release.Version; + else if (IsEnabled(release)) _enabled.Remove(release.Id); + 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), + Comparer.Create((a, b) => a.ComparePrecedenceTo(b))).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.Sha256, release.DllFileName ?? release.Name + ".dll"); + + 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 async Task PrepareAsync(bool server = false, bool allowDownloads = true) { + foreach (var release in SelectedReleases(server)) { + var path = GetPath(release); + if (File.Exists(path)) { + // A modified installed DLL fails closed; do not silently overwrite it. + using var existing = File.OpenRead(path); + ValidateBytes(existing, release); + continue; + } + if (!allowDownloads) + throw new InvalidOperationException($"Automatic plugin downloads are disabled; missing registered DLL: {release.Key}"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temp = path + "." + 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, path, true); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + } + + // 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(); + } +} diff --git a/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs b/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs new file mode 100644 index 00000000..e28347e4 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs @@ -0,0 +1,130 @@ +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Semver; +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}"; + + 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); + } +} diff --git a/UnchainedLauncher.Core/Services/Mods/IModManager.cs b/UnchainedLauncher.Core/Services/Mods/IModManager.cs index b3f7e29a..27aeb3dc 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 @@ -405,4 +405,4 @@ private static ImmutableHashSet AggregateUniqueDependencies(this IModMa ); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs index 79ca1dbb..bbd3dc0b 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) => @@ -97,4 +97,4 @@ public record PakDirMetadata(string DirPath, IEnumerable ManagedPaks internal static class ModManagerMetadataKind { public const string StandardModManager = "StandardModManager"; } -} \ 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..72e0a320 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs @@ -13,8 +13,10 @@ public class Chivalry2ModsInstaller : IChivalry2LaunchPreparer { private readonly ILog _logger = LogManager.GetLogger(typeof(Chivalry2ModsInstaller)); private readonly IUserDialogueSpawner _userDialogueSpawner; private IModManager _modManager { get; } + private readonly bool _allowPluginDependencies; - public Chivalry2ModsInstaller(IModManager modManager, IUserDialogueSpawner dialogueSpawner) { + public Chivalry2ModsInstaller(IModManager modManager, IUserDialogueSpawner dialogueSpawner, bool allowPluginDependencies = false) { + _allowPluginDependencies = allowPluginDependencies; _modManager = modManager; _userDialogueSpawner = dialogueSpawner; } @@ -74,6 +76,10 @@ private async Task ValidateHashes(IEnumerable releases) { public async Task> PrepareLaunch(LaunchOptions options) { // Get all releases that will be installed (enabled mods + dependencies) var releasesToInstall = _modManager.GetEnabledAndDependencyReleases().ToList(); + if (!_allowPluginDependencies && releasesToInstall.Any(release => release.Info.EffectivePluginDependencies.Count != 0)) { + _userDialogueSpawner.DisplayMessage("An enabled mod requires a DLL plugin. Use Unchained launch and enable its registered plugin dependencies in Plugin Manager."); + return None; + } _logger.LogListInfo("Installing the following releases:", releasesToInstall.Select(ReleaseCoordinates.FromRelease).ToList()); // Validate hashes before installation @@ -107,4 +113,4 @@ public async Task> PrepareLaunch(LaunchOptions options) { 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..62ef919a --- /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.DllFileName ?? latest.Name + ".dll", current.Version, latest.Version, + $"https://github.com/{latest.Id}/releases/tag/{latest.Tag ?? latest.Version}", 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; + } + } + } +} diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs index ad9a4ec6..801174de 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs @@ -88,17 +88,17 @@ 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()); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Processes/DllInjector.cs b/UnchainedLauncher.Core/Services/Processes/DllInjector.cs index 2cebad19..354f3ee3 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..3c76d8b8 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); } @@ -28,4 +30,4 @@ public bool Inject(Process p) { return _result; } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Utilities/FilePaths.cs b/UnchainedLauncher.Core/Utilities/FilePaths.cs index a5378ec5..42e6e4ff 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) { @@ -21,4 +22,4 @@ public static string Chiv2ConfigPath(string? savedDirSuffix) { return Environment.ExpandEnvironmentVariables(unexpandedPath); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs new file mode 100644 index 00000000..be5c410d --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs @@ -0,0 +1,416 @@ +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 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.PackageListUrl ? "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.PackageListUrl + ? 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); } + } + + 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 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 UpdateDialogue(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"), Release("UnchainedPlugin", "3.0.0-RC1"), + 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" : "2.0.0"; + Assert.Equal(newest, dialogue.Updates.Single(update => update.Name == "UnchainedPlugin tools").LatestVersion); + 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.Equal("No plugin updates available.", vm.Status); + handler.Fail = true; + await vm.UpdatePlugins(); + Assert.StartsWith("Could not update plugins:", vm.Status); + Assert.Equal(vm.Status, 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.PackageListUrl + ? System.Net.HttpStatusCode.NotFound : System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new StringContent(json) }); + } + } +} 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/ReleaseBadgeTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ReleaseBadgeTests.cs new file mode 100644 index 00000000..26c38076 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/ReleaseBadgeTests.cs @@ -0,0 +1,40 @@ +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}"); + } + + 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..0e598579 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 9636d494..a2d8d586 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Net.Http; using System.Reflection; using System.Threading.Tasks; using System.Windows; @@ -12,6 +13,7 @@ using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Installer; using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Dlls; using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.Services.Processes; using UnchainedLauncher.Core.Services.Processes.Chivalry; @@ -73,7 +75,6 @@ protected override async void OnStartup(StartupEventArgs e) { 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(); @@ -91,8 +92,7 @@ protected override async void OnStartup(StartupEventArgs e) { var window = needsInstallation && !forceSkipInstallation ? InitializeInstallerWindow(installationFinder, installer, unchainedLauncherReleaseLocator) - : await InitializeMainWindow(installationFinder, installer, unchainedLauncherReleaseLocator, - pluginReleaseLocator); + : await InitializeMainWindow(installationFinder, installer, unchainedLauncherReleaseLocator); window?.Show(); } @@ -134,17 +134,23 @@ select chiv2Installations.Path } private async Task InitializeMainWindow(IChivalry2InstallationFinder installationFinder, - IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator, - IReleaseLocator pluginReleaseLocator) { + 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.PackageDBBaseUrl[..^"package_db".Length] + "dll_db").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( @@ -166,14 +172,12 @@ select chiv2Installations.Path var noSigLaunchPreparer = NoSigPreparer.Create(modManager.PakDir, userDialogueSpawner); var sigLaunchPreparer = SigPreparer.Create(modManager.PakDir, userDialogueSpawner); - IChivalry2LaunchPreparer pluginInstaller = new UnchainedPluginUpdateChecker( - pluginReleaseLocator, - new FileInfoVersionExtractor(), - userDialogueSpawner); + IChivalry2LaunchPreparer pluginInstaller = new DllLaunchPreparer(dllManager, userDialogueSpawner); IChivalry2LaunchPreparer modInstaller = new Chivalry2ModsInstaller( - modManager, userDialogueSpawner + modManager, userDialogueSpawner, allowPluginDependencies: true ); + IChivalry2LaunchPreparer clientsideModInstaller = new Chivalry2ModsInstaller(modManager, userDialogueSpawner); var vanillaLauncher = new Chivalry2Launcher( noSigLaunchPreparer.IgnoreOptions(), @@ -182,7 +186,7 @@ select chiv2Installations.Path ); var clientsideModdedLauncher = new Chivalry2Launcher( - modInstaller + clientsideModInstaller .Sub(sigLaunchPreparer), officialProcessLauncher, Directory.GetCurrentDirectory() @@ -198,7 +202,7 @@ select chiv2Installations.Path // 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)) + new DllInjector(dllManager) #endif ); @@ -255,7 +259,8 @@ select chiv2Installations.Path settingsViewModel, serversTabViewModel, modScanTab, - helpVM + helpVM, + dllManagerViewModel ); return new MainWindow(mainWindowViewModel); @@ -424,4 +429,4 @@ private void ExecuteExitAction(string source, Action exitAction) { } } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.GUI/Converters/ModSortModeToStringConverter.cs b/UnchainedLauncher.GUI/Converters/ModSortModeToStringConverter.cs index 1982140b..96656473 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() ?? "" }; @@ -20,4 +20,4 @@ public class ModSortModeToStringConverter : IValueConverter { return Binding.DoNothing; } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.GUI/JsonModels/LauncherSettings.cs b/UnchainedLauncher.GUI/JsonModels/LauncherSettings.cs index 92c6e430..21f5dacb 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 - ); -} \ No newline at end of file + ) { + public bool ShouldAllowUnstablePluginReleases(SemVersion runningVersion) => runningVersion.IsPrerelease + || (LastLaunchVersion?.WithoutMetadata() == runningVersion.WithoutMetadata() && AllowUnstablePluginReleases == true); + } +} diff --git a/UnchainedLauncher.GUI/Resources/Themes/Misc.xaml b/UnchainedLauncher.GUI/Resources/Themes/Misc.xaml index 3aa065d9..4a426bfb 100644 --- a/UnchainedLauncher.GUI/Resources/Themes/Misc.xaml +++ b/UnchainedLauncher.GUI/Resources/Themes/Misc.xaml @@ -1,6 +1,25 @@  + + + + + + - \ 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..ca2de3ef --- /dev/null +++ b/UnchainedLauncher.GUI/ViewModels/DllManagerVM.cs @@ -0,0 +1,260 @@ +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.Dlls; +using UnchainedLauncher.Core.Services; + +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(); + }; + } + public string Status { get; set; } = "Only enabled, registry-validated DLLs are loaded. Trust the publisher before enabling native code."; + + private void Populate() { + IEnumerable query = manager.Releases.GroupBy(release => release.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => new DllPluginVM(manager, group.ToArray(), AllowUnstablePluginReleases, message => Status = message, ReportError)) + .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(); + Status = "Plugin list refreshed. Automatic updates are checked at launch when enabled."; + } + 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) { + Status = "No plugin updates available."; + return; + } + var details = updates.Select(update => new DependencyUpdate(update.Latest.Info?.Name ?? update.Latest.Id, + update.Current.Version, update.Latest.Version, + $"https://github.com/{update.Latest.Id}/releases/tag/{update.Latest.Tag ?? update.Latest.Version}", + update.Latest.Info?.Description ?? "Native plugin")).ToArray(); + if (_dialogue?.DisplayUpdateMessage("Update Plugins?", "Plugin updates available.", "Yes", "No", null, details) != UserDialogueChoice.Yes) { + Status = "Plugins not updated."; + return; + } + foreach (var update in updates) manager.SetEnabled(update.Latest, true); + Status = "Plugins updated. Registered DLLs will be validated at launch; downloads follow your settings."; + } + catch (Exception ex) { ReportError($"Could not update plugins: {ex.Message}"); } + Populate(); + } + + private void ReportError(string message) { + Status = message; + _dialogue?.DisplayMessage(message); + } + + } + + public partial class DllPluginVM : INotifyPropertyChanged { + public event PropertyChangedEventHandler? PropertyChanged; + private readonly DllManager _manager; + private readonly Action _status; + 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?.DllFileName ?? 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 is { } release + ? $"{RepositoryUrl}/releases/tag/{Uri.EscapeDataString(release.Tag ?? release.Version)}" : RepositoryUrl; + public string Name => Info?.Name ?? RepositoryName; + public string Description => 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 bool IsEnabled => EnabledRelease != null; + public string ButtonText => IsEnabled ? "Disable" : "Enable"; + public bool HasHiddenEnabledVersion => IsEnabled && !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; + NotifySelectionChanged(); + _status("Enabled. The DLL will be validated at next Unchained launch; downloads follow your settings."); + } + catch (Exception ex) { _error($"Could not save DLL selection: {ex.Message}"); } + } + } + + public DllPluginVM(DllManager manager, IReadOnlyList releases, bool showPrereleases, Action status, + Action? error = null, Action? openUrl = null) { + _manager = manager; + _status = status; + _error = error ?? status; + _openUrl = openUrl ?? (url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true })); + Organization = releases[0].Organization; + RepositoryName = releases[0].Name; + 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, + Comparer.Create((a, b) => a.ComparePrecedenceTo(b))).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 (EnabledRelease != null) { + _manager.SetEnabled(EnabledRelease, false); + EnabledRelease = null; + _selectedVersion = null; + NotifySelectionChanged(); + _status("Disabled. The DLL will not be injected."); + } + 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(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; + } +} diff --git a/UnchainedLauncher.GUI/ViewModels/MainWindowVM.cs b/UnchainedLauncher.GUI/ViewModels/MainWindowVM.cs index 7a8ad4de..302d4e66 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; @@ -33,4 +36,4 @@ public void Dispose() { // TODO: Servers tab needs to be disposable } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.GUI/ViewModels/ModListVM.cs b/UnchainedLauncher.GUI/ViewModels/ModListVM.cs index b17e1259..fcc3a8fa 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..f87c8701 --- /dev/null +++ b/UnchainedLauncher.GUI/ViewModels/ModSortMode.cs @@ -0,0 +1,8 @@ +namespace UnchainedLauncher.GUI.ViewModels { + public enum ModSortMode { + EnabledFirst, + Alphabetical, + LatestReleaseDateFirst, + NewestModsFirst + } +} diff --git a/UnchainedLauncher.GUI/ViewModels/ModVM.cs b/UnchainedLauncher.GUI/ViewModels/ModVM.cs index 7a69950f..42b5437d 100644 --- a/UnchainedLauncher.GUI/ViewModels/ModVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/ModVM.cs @@ -69,6 +69,7 @@ public string Description { 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)) @@ -80,6 +81,9 @@ public string Description { .Bind(r => ModManager.GetAllDependenciesForRelease(r)) .ToList(); + public List PluginDependencies => + (EnabledRelease ?? Mod.LatestRelease.FirstOrDefault())?.Info.EffectivePluginDependencies ?? []; + public string ButtonText => Optional(EnabledRelease).Match( None: () => "Enable", @@ -182,4 +186,4 @@ public int CompareTo(VersionNameSort? other) { 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..04885148 100644 --- a/UnchainedLauncher.GUI/ViewModels/Registry/LocalModRegistryDetailsVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/Registry/LocalModRegistryDetailsVM.cs @@ -243,7 +243,7 @@ public RegistryReleaseFormVM(Release release, string? pakFilePath) { IconUrl = release.Info.IconUrl; ImageUrls = release.Info.ImageUrls; Authors = release.Info.Authors; - Dependencies = release.Info.Dependencies; + Dependencies = release.Info.EffectiveModDependencies; ReleaseNotesMarkdown = release.ReleaseNotesMarkdown ?? ""; } @@ -275,4 +275,4 @@ public RegistryReleaseFormVM(Release release, string? pakFilePath) { return (r, PakFilePath); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.GUI/ViewModels/SettingsVM.cs b/UnchainedLauncher.GUI/ViewModels/SettingsVM.cs index 0333aa21..87bae79f 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); @@ -132,4 +130,4 @@ public static InstallationType DetectInstallationType(IChivalry2InstallationFind } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.GUI/Views/MainWindow.xaml b/UnchainedLauncher.GUI/Views/MainWindow.xaml index 2c14bb51..fe02de13 100644 --- a/UnchainedLauncher.GUI/Views/MainWindow.xaml +++ b/UnchainedLauncher.GUI/Views/MainWindow.xaml @@ -8,6 +8,7 @@ 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 +158,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Command="{Binding EnableOrDisableCommand}" Margin="0,0,8,0" + Style="{StaticResource Button.EnableOrDisable}" /> diff --git a/UnchainedLauncher.GUI/Views/Mods/Mod.xaml b/UnchainedLauncher.GUI/Views/Mods/Mod.xaml index 29bf7622..c3d947ad 100644 --- a/UnchainedLauncher.GUI/Views/Mods/Mod.xaml +++ b/UnchainedLauncher.GUI/Views/Mods/Mod.xaml @@ -53,20 +53,7 @@ + Margin="0,0,8,0" Style="{StaticResource Button.EnableOrDisable}" />