diff --git a/.trae/specs/plonds-client-service/spec.md b/.trae/specs/plonds-client-service/spec.md index 26eab57..9cdc3b2 100644 --- a/.trae/specs/plonds-client-service/spec.md +++ b/.trae/specs/plonds-client-service/spec.md @@ -113,6 +113,7 @@ Publisher 上传到 S3 的版本目录: - `Files.zip` 是上传到 S3 时的完整包标准名。 - `-Files/` 是 S3 上解压后的完整包目录。 +- `/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。 - GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。 - `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。 diff --git a/LanMountainDesktop.Tests/PlondsClientServiceTests.cs b/LanMountainDesktop.Tests/PlondsClientServiceTests.cs new file mode 100644 index 0000000..1ada3ad --- /dev/null +++ b/LanMountainDesktop.Tests/PlondsClientServiceTests.cs @@ -0,0 +1,648 @@ +using System.Net; +using System.Security.Cryptography; +using System.IO.Compression; +using LanMountainDesktop.Services.Plonds; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class PlondsClientServiceTests : IDisposable +{ + private readonly string _tempRoot = Path.Combine( + Path.GetTempPath(), + "LanMountainDesktop.Tests", + nameof(PlondsClientServiceTests), + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + + [Fact] + public void SourceRegistry_AddRange_DeduplicatesAndAllowsManifestExtensions() + { + var registry = new PlondsSourceRegistry( + [ + new("s3", "s3", "https://s3.test/PLONDS.json", 100), + new("github", "github", "https://github.test/PLONDS.json", 50) + ]); + + registry.AddRange( + [ + new("mirror", "http", "https://mirror.test/PLONDS.json", 10), + new("s3", "s3", "https://s3-new.test/PLONDS.json", 200), + new("duplicate-url", "http", "https://mirror.test/PLONDS.json", 1) + ]); + + Assert.Equal(3, registry.Sources.Count); + Assert.Contains(registry.Sources, source => source.Id == "s3" && source.ManifestUrl == "https://s3-new.test/PLONDS.json"); + Assert.Contains(registry.Sources, source => source.Id == "mirror"); + } + + [Fact] + public void ManifestSelector_WhenVersionsDiffer_SelectsHighestVersion() + { + var selected = PlondsManifestSelector.SelectHighestVersion( + [ + new(new("s3", "s3", "https://s3.test/PLONDS.json", 100), CreateManifest("1.2.0")), + new(new("github", "github", "https://github.test/PLONDS.json", 50), CreateManifest("1.3.0")), + new(new("mirror", "http", "https://mirror.test/PLONDS.json", 500), CreateManifest("1.1.9")) + ]); + + Assert.NotNull(selected); + Assert.Equal("1.3.0", selected.Manifest.CurrentVersion); + Assert.Equal("github", selected.Source.Id); + } + + [Fact] + public async Task DownloadPlanner_WhenDeltaFails_FallsBackToFullPackage() + { + var downloader = new FakeDownloader(deltaFails: true, fullFails: false); + var planner = new PlondsDownloadPlanner(downloader); + + var result = await planner.PrepareAsync( + new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/PLONDS.json"), CreateManifest("1.2.3")), + CancellationToken.None); + + Assert.True(result.Success); + Assert.False(result.RequiresUiHandling); + Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode); + Assert.Equal(1, downloader.DeltaCalls); + Assert.Equal(1, downloader.FullCalls); + } + + [Fact] + public async Task DownloadPlanner_WhenDeltaAndFullFail_ReturnsUiFailure() + { + var downloader = new FakeDownloader(deltaFails: true, fullFails: true); + var planner = new PlondsDownloadPlanner(downloader); + + var result = await planner.PrepareAsync( + new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/PLONDS.json"), CreateManifest("1.2.3")), + CancellationToken.None); + + Assert.False(result.Success); + Assert.True(result.RequiresUiHandling); + Assert.Null(result.Package); + Assert.Contains("full package fallback also failed", result.ErrorMessage); + } + + [Fact] + public async Task PlondsService_ReadsBuiltInSources_RegistersManifestSources_AndPreparesHighestVersion() + { + using var httpClient = new HttpClient(new ManifestHandler(new Dictionary + { + ["https://s3.test/PLONDS.json"] = ManifestJson("1.2.0", """ + "sources": [ + { "id": "mirror", "kind": "http", "manifestUrl": "https://mirror.test/PLONDS.json", "priority": 25 } + ] + """), + ["https://github.test/PLONDS.json"] = ManifestJson("1.3.0") + })); + + var registry = new PlondsSourceRegistry( + [ + new("s3", "s3", "https://s3.test/PLONDS.json", 100), + new("github", "github", "https://github.test/PLONDS.json", 50) + ]); + var downloader = new FakeDownloader(deltaFails: false, fullFails: false); + var service = new PlondsService( + registry, + new PlondsManifestClient(httpClient), + new PlondsDownloadPlanner(downloader)); + + var result = await service.FindAndPrepareLatestAsync(CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal("1.3.0", result.Package?.Version.ToString()); + Assert.Equal(PlondsPackageMode.Delta, result.Package?.Mode); + Assert.Contains(registry.Sources, source => source.Id == "mirror" && source.ManifestUrl == "https://mirror.test/PLONDS.json"); + } + + [Fact] + public void ClientServiceFactory_CreatesBuiltInS3AndGitHubSources() + { + var sources = PlondsClientServiceFactory.CreateBuiltInSources(); + + Assert.Equal(2, sources.Count); + Assert.Contains(sources, source => source.Id == "s3" && source.Kind == "s3" && source.ManifestUrl.EndsWith("/PLONDS.json", StringComparison.Ordinal)); + Assert.Contains(sources, source => source.Id == "github" && source.Kind == "github" && source.ManifestUrl.EndsWith("/PLONDS.json", StringComparison.Ordinal)); + } + + [Fact] + public async Task PlondsService_FindLatest_UsesHighestVersionAndPersistsManifestSources() + { + using var httpClient = new HttpClient(new ManifestHandler(new Dictionary + { + ["https://s3.test/PLONDS.json"] = ManifestJson("1.5.0", """ + "sources": [ + { "id": "mirror", "kind": "http", "manifestUrl": "https://mirror.test/PLONDS.json", "priority": 25 } + ] + """), + ["https://github.test/PLONDS.json"] = ManifestJson("1.4.0") + })); + var sourceStorePath = Path.Combine(_tempRoot, "sources.json"); + var sourceStore = new PlondsSourceStore(sourceStorePath); + var registry = new PlondsSourceRegistry( + [ + new("s3", "s3", "https://s3.test/PLONDS.json", 100), + new("github", "github", "https://github.test/PLONDS.json", 50) + ]); + var service = new PlondsService( + registry, + new PlondsManifestClient(httpClient), + new PlondsDownloadPlanner(new FakeDownloader(deltaFails: false, fullFails: false)), + sourceStore); + + var result = await service.FindLatestAsync(new Version(1, 4, 0), CancellationToken.None); + var storedSources = await sourceStore.LoadAsync(CancellationToken.None); + + Assert.True(result.Success); + Assert.True(result.IsUpdateAvailable); + Assert.Equal("1.5.0", result.LatestVersion?.ToString()); + Assert.Contains(storedSources, source => source.Id == "mirror" && source.ManifestUrl == "https://mirror.test/PLONDS.json"); + } + + [Fact] + public async Task PlondsService_WhenHighestVersionSourcePackageFails_TriesSameVersionOtherSource() + { + using var httpClient = new HttpClient(new ManifestHandler(new Dictionary + { + ["https://s3.test/PLONDS.json"] = ManifestJson("1.6.0"), + ["https://github.test/PLONDS.json"] = ManifestJson("1.6.0") + })); + var registry = new PlondsSourceRegistry( + [ + new("s3", "s3", "https://s3.test/PLONDS.json", 100), + new("github", "github", "https://github.test/PLONDS.json", 50) + ]); + var downloader = new SourceAwareFakeDownloader(failingSourceId: "s3"); + var service = new PlondsService( + registry, + new PlondsManifestClient(httpClient), + new PlondsDownloadPlanner(downloader)); + + var result = await service.FindAndPrepareLatestAsync(new Version(1, 5, 0), CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal("github", downloader.SuccessfulSourceId); + Assert.Equal(2, downloader.DeltaCalls); + } + + [Fact] + public async Task PlondsService_WhenManifestSourceThrows_ContinuesWithOtherSources() + { + using var httpClient = new HttpClient(new ManifestHandler( + new Dictionary + { + ["https://github.test/PLONDS.json"] = ManifestJson("1.7.0") + }, + throwingUrls: new HashSet(StringComparer.OrdinalIgnoreCase) + { + "https://s3.test/PLONDS.json" + })); + var registry = new PlondsSourceRegistry( + [ + new("s3", "s3", "https://s3.test/PLONDS.json", 100), + new("github", "github", "https://github.test/PLONDS.json", 50) + ]); + var service = new PlondsService( + registry, + new PlondsManifestClient(httpClient), + new PlondsDownloadPlanner(new FakeDownloader(deltaFails: false, fullFails: false))); + + var result = await service.FindLatestAsync(new Version(1, 6, 0), CancellationToken.None); + + Assert.True(result.Success); + Assert.True(result.IsUpdateAvailable); + Assert.Equal("1.7.0", result.LatestVersion?.ToString()); + Assert.Equal("github", Assert.Single(result.Candidates).Source.Id); + } + + [Fact] + public async Task HttpDownloader_DownloadsVerifiesAndExtractsDeltaPackage() + { + var changedZip = CreateZip(("app.dll", "delta payload")); + var filesZip = CreateZip(("app.dll", "full payload")); + var manifest = CreateManifest( + "1.4.0", + downloads: CreateDownloads( + changedUrl: "https://s3.test/1.4.0/changed.zip", + filesUrl: "https://s3.test/1.4.0/Files.zip"), + checksums: new Dictionary + { + ["changed.zip"] = Md5Checksum(changedZip), + ["Files.zip"] = Md5Checksum(filesZip) + }); + + using var httpClient = new HttpClient(new AssetHandler(new Dictionary + { + ["https://s3.test/1.4.0/changed.zip"] = changedZip, + ["https://s3.test/1.4.0/Files.zip"] = filesZip + })); + var downloader = CreateHttpDownloader(httpClient); + + var package = await downloader.PrepareDeltaAsync( + manifest, + new("s3", "s3", "https://s3.test/1.4.0/PLONDS.json", 100), + CancellationToken.None); + + Assert.Equal(PlondsPackageMode.Delta, package.Mode); + Assert.True(File.Exists(package.ManifestPath)); + Assert.True(File.Exists(package.ChangedZipPath)); + Assert.Equal("delta payload", File.ReadAllText(Path.Combine(package.ChangedDirectory!, "app.dll"))); + } + + [Fact] + public async Task DownloadPlanner_WhenDeltaChecksumFails_PreparesFullPackage() + { + var changedZip = CreateZip(("app.dll", "delta payload")); + var filesZip = CreateZip(("app.dll", "full payload")); + var manifest = CreateManifest( + "1.4.1", + downloads: CreateDownloads( + changedUrl: "https://s3.test/1.4.1/changed.zip", + filesUrl: "https://s3.test/1.4.1/Files.zip"), + checksums: new Dictionary + { + ["changed.zip"] = "md5:00000000000000000000000000000000", + ["Files.zip"] = Md5Checksum(filesZip) + }); + + using var httpClient = new HttpClient(new AssetHandler(new Dictionary + { + ["https://s3.test/1.4.1/changed.zip"] = changedZip, + ["https://s3.test/1.4.1/Files.zip"] = filesZip + })); + var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient)); + + var result = await planner.PrepareAsync( + new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.1/PLONDS.json", 100), manifest), + CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode); + Assert.Equal("full payload", File.ReadAllText(Path.Combine(result.Package!.FilesDirectory!, "app.dll"))); + } + + [Fact] + public async Task DownloadPlanner_WhenDeltaUrlMissing_PreparesFullPackage() + { + var filesZip = CreateZip(("app.dll", "full payload")); + var manifest = CreateManifest( + "1.4.2", + downloads: CreateDownloads( + changedUrl: null, + filesUrl: "https://s3.test/1.4.2/Files.zip"), + checksums: new Dictionary + { + ["Files.zip"] = Md5Checksum(filesZip) + }); + + using var httpClient = new HttpClient(new AssetHandler(new Dictionary + { + ["https://s3.test/1.4.2/Files.zip"] = filesZip + })); + var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient)); + + var result = await planner.PrepareAsync( + new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.2/PLONDS.json", 100), manifest), + CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal(PlondsPackageMode.Full, result.Package?.Mode); + } + + [Fact] + public async Task DownloadPlanner_WhenFullChecksumFails_ReturnsUiFailure() + { + var changedZip = CreateZip(("app.dll", "delta payload")); + var filesZip = CreateZip(("app.dll", "full payload")); + var manifest = CreateManifest( + "1.4.3", + downloads: CreateDownloads( + changedUrl: "https://s3.test/1.4.3/changed.zip", + filesUrl: "https://s3.test/1.4.3/Files.zip"), + checksums: new Dictionary + { + ["changed.zip"] = "md5:00000000000000000000000000000000", + ["Files.zip"] = "md5:11111111111111111111111111111111" + }); + + using var httpClient = new HttpClient(new AssetHandler(new Dictionary + { + ["https://s3.test/1.4.3/changed.zip"] = changedZip, + ["https://s3.test/1.4.3/Files.zip"] = filesZip + })); + var planner = new PlondsDownloadPlanner(CreateHttpDownloader(httpClient)); + + var result = await planner.PrepareAsync( + new PlondsManifestCandidate(new("s3", "s3", "https://s3.test/1.4.3/PLONDS.json", 100), manifest), + CancellationToken.None); + + Assert.False(result.Success); + Assert.True(result.RequiresUiHandling); + Assert.Contains("full package fallback also failed", result.ErrorMessage); + } + + [Fact] + public async Task PreparedPackageInstaller_AppliesDeltaPackageWithoutUpdateDownloadSystem() + { + var launcherRoot = Path.Combine(_tempRoot, "launcher"); + var currentDeployment = Path.Combine(launcherRoot, "app-1.0.0-0"); + Directory.CreateDirectory(currentDeployment); + File.WriteAllText(Path.Combine(currentDeployment, ".current"), string.Empty); + File.WriteAllText(Path.Combine(currentDeployment, "LanMountainDesktop.exe"), "exe"); + File.WriteAllText(Path.Combine(currentDeployment, "app.dll"), "old"); + File.WriteAllText(Path.Combine(currentDeployment, "keep.txt"), "keep"); + File.WriteAllText(Path.Combine(currentDeployment, "delete.txt"), "delete"); + + var changedDirectory = Path.Combine(_tempRoot, "changed"); + Directory.CreateDirectory(changedDirectory); + File.WriteAllText(Path.Combine(changedDirectory, "app.dll"), "new"); + var manifestPath = Path.Combine(_tempRoot, "PLONDS.json"); + await File.WriteAllTextAsync(manifestPath, $$""" + { + "formatVersion": "2.0", + "currentVersion": "1.1.0", + "previousVersion": "1.0.0", + "isFullUpdate": false, + "requiresCleanInstall": false, + "channel": "stable", + "platform": "windows-x64", + "updatedAt": "2026-06-01T00:00:00Z", + "filesMap": { + "LanMountainDesktop.exe": { "action": "reuse", "hash": "{{Sha256Text("exe")}}", "size": 3 }, + "app.dll": { "action": "replace", "hash": "{{Sha256Text("new")}}", "size": 3 }, + "keep.txt": { "action": "reuse", "hash": "{{Sha256Text("keep")}}", "size": 4 }, + "delete.txt": { "action": "delete", "hash": "", "size": 0 } + }, + "changedFilesMap": { + "app.dll": { "archivePath": "app.dll", "hash": "{{Sha256Text("new")}}", "size": 3 } + }, + "checksums": {} + } + """); + var package = new PlondsPreparedPackage( + new Version(1, 1, 0), + PlondsPackageMode.Delta, + manifestPath, + Path.Combine(_tempRoot, "changed.zip"), + changedDirectory, + null, + null); + + var result = await new PlondsPreparedPackageInstaller().InstallAsync( + package, + launcherRoot, + progress: null, + CancellationToken.None); + + Assert.True(result.Success); + var target = Assert.Single(Directory.GetDirectories(launcherRoot, "app-1.1.0-*")); + Assert.Equal("new", File.ReadAllText(Path.Combine(target, "app.dll"))); + Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt"))); + Assert.False(File.Exists(Path.Combine(target, "delete.txt"))); + Assert.True(File.Exists(Path.Combine(target, ".current"))); + Assert.True(File.Exists(Path.Combine(currentDeployment, ".destroy"))); + } + + private static PlondsClientManifest CreateManifest( + string version, + IReadOnlyList? sources = null, + PlondsClientDownloads? downloads = null, + IReadOnlyDictionary? checksums = null) + { + return new PlondsClientManifest( + FormatVersion: "2.0", + CurrentVersion: version, + PreviousVersion: "1.0.0", + IsFullUpdate: false, + RequiresCleanInstall: false, + Channel: "stable", + Platform: "windows-x64", + UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"), + FilesMap: new Dictionary(), + ChangedFilesMap: new Dictionary(), + Checksums: checksums ?? new Dictionary(), + Downloads: downloads, + Sources: sources ?? []); + } + + private PlondsHttpPackageDownloader CreateHttpDownloader(HttpClient httpClient) + { + return new PlondsHttpPackageDownloader( + httpClient, + new PlondsPackageStore(_tempRoot), + new PlondsVerifier()); + } + + private static PlondsClientDownloads CreateDownloads(string? changedUrl, string? filesUrl) + { + return new PlondsClientDownloads( + GitHub: null, + S3: new PlondsS3Downloads( + Bucket: "bucket", + Prefix: "lanmountain/update/plonds/1.4.0", + ManifestKey: "lanmountain/update/plonds/1.4.0/PLONDS.json", + ManifestUrl: "https://s3.test/1.4.0/PLONDS.json", + ChangedZipKey: changedUrl is null ? null : "lanmountain/update/plonds/1.4.0/changed.zip", + ChangedZipUrl: changedUrl, + ChangedFolderKey: null, + ChangedFolderUrl: null, + FilesZipKey: filesUrl is null ? null : "lanmountain/update/plonds/1.4.0/Files.zip", + FilesZipUrl: filesUrl, + FilesFolderKey: null, + FilesFolderUrl: null)); + } + + private static byte[] CreateZip(params (string Path, string Contents)[] entries) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (path, contents) in entries) + { + var entry = archive.CreateEntry(path); + using var writer = new StreamWriter(entry.Open()); + writer.Write(contents); + } + } + + return stream.ToArray(); + } + + private static string Md5Checksum(byte[] bytes) + { + return $"md5:{Convert.ToHexString(MD5.HashData(bytes)).ToLowerInvariant()}"; + } + + private static string Sha256Text(string text) + { + return Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); + } + + private static string ManifestJson(string version, string extraFields = "") + { + var separator = string.IsNullOrWhiteSpace(extraFields) ? string.Empty : ","; + return $$""" + { + "formatVersion": "2.0", + "currentVersion": "{{version}}", + "previousVersion": "1.0.0", + "isFullUpdate": false, + "requiresCleanInstall": false, + "channel": "stable", + "platform": "windows-x64", + "updatedAt": "2026-06-01T00:00:00Z", + "filesMap": {}, + "changedFilesMap": {}, + "checksums": {}{{separator}} + {{extraFields}} + } + """; + } + + private sealed class FakeDownloader(bool deltaFails, bool fullFails) : IPlondsPackageDownloader + { + public int DeltaCalls { get; private set; } + public int FullCalls { get; private set; } + + public Task PrepareDeltaAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + CancellationToken cancellationToken) + { + DeltaCalls++; + if (deltaFails) + { + throw new InvalidOperationException("delta failed"); + } + + return Task.FromResult(CreatePackage(manifest, PlondsPackageMode.Delta)); + } + + public Task PrepareFullAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + CancellationToken cancellationToken) + { + FullCalls++; + if (fullFails) + { + throw new InvalidOperationException("full failed"); + } + + return Task.FromResult(CreatePackage(manifest, PlondsPackageMode.Full)); + } + + private static PlondsPreparedPackage CreatePackage(PlondsClientManifest manifest, PlondsPackageMode mode) + { + PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version); + return new PlondsPreparedPackage( + version, + mode, + "PLONDS.json", + mode is PlondsPackageMode.Delta ? "changed.zip" : null, + mode is PlondsPackageMode.Delta ? "changed" : null, + mode is PlondsPackageMode.Full ? "Files.zip" : null, + mode is PlondsPackageMode.Full ? "Files" : null); + } + } + + private sealed class SourceAwareFakeDownloader(string failingSourceId) : IPlondsPackageDownloader + { + public int DeltaCalls { get; private set; } + public string? SuccessfulSourceId { get; private set; } + + public Task PrepareDeltaAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + CancellationToken cancellationToken) + { + DeltaCalls++; + if (string.Equals(source.Id, failingSourceId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("source failed"); + } + + SuccessfulSourceId = source.Id; + return Task.FromResult(CreatePackage(manifest, source, PlondsPackageMode.Delta)); + } + + public Task PrepareFullAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + CancellationToken cancellationToken) + { + if (string.Equals(source.Id, failingSourceId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("source full failed"); + } + + SuccessfulSourceId = source.Id; + return Task.FromResult(CreatePackage(manifest, source, PlondsPackageMode.Full)); + } + + private static PlondsPreparedPackage CreatePackage( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + PlondsPackageMode mode) + { + PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version); + return new PlondsPreparedPackage( + version, + mode, + $"{source.Id}/PLONDS.json", + mode is PlondsPackageMode.Delta ? $"{source.Id}/changed.zip" : null, + mode is PlondsPackageMode.Delta ? $"{source.Id}/changed" : null, + mode is PlondsPackageMode.Full ? $"{source.Id}/Files.zip" : null, + mode is PlondsPackageMode.Full ? $"{source.Id}/Files" : null); + } + } + + private sealed class ManifestHandler( + IReadOnlyDictionary manifests, + IReadOnlySet? throwingUrls = null) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri?.ToString() ?? string.Empty; + if (throwingUrls?.Contains(url) == true) + { + throw new HttpRequestException("manifest source failed"); + } + + if (!manifests.TryGetValue(url, out var json)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + } + } + + private sealed class AssetHandler(IReadOnlyDictionary assets) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri?.ToString() ?? string.Empty; + if (!assets.TryGetValue(url, out var bytes)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(bytes) + }); + } + } +} diff --git a/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs b/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs index 9030d31..3b2338f 100644 --- a/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs +++ b/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs @@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.Input; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Plonds; using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Update; using LanMountainDesktop.Shared.Contracts.Update; @@ -103,35 +104,74 @@ public sealed class UpdateSettingsInterfaceTests } [Fact] - public async Task SettingsUpdateManifestProvider_UsesSelectedUpdateSource() + public async Task UpdateSettingsService_WhenPlondsSelected_UsesPlondsServiceWithoutCreatingOrchestrator() { - var update = new FakeUpdateSettingsService + var settings = new FakeSettingsService { - State = DefaultUpdateState() with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub } + Snapshot = + { + UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds + } }; - var plonds = new FakeManifestProvider("plonds"); - var github = new FakeManifestProvider("github"); - var provider = new SettingsUpdateManifestProvider(new FakeSettingsFacade(update), plonds, github); + var plonds = new FakePlondsService + { + LatestResult = PlondsLatestResult.Available( + new Version(1, 0, 0), + new Version(9, 9, 9), + [new PlondsManifestCandidate( + new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100), + CreatePlondsManifest("9.9.9"))]) + }; + var orchestratorCreated = false; + var service = new UpdateSettingsService( + settings, + orchestratorFactory: () => + { + orchestratorCreated = true; + throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS."); + }, + plondsService: plonds); - var manifest = await provider.GetLatestAsync( - UpdateSettingsValues.ChannelStable, - "windows-x64", - new Version(1, 0, 0), - CancellationToken.None); + var report = await service.CheckAsync(CancellationToken.None); - Assert.Equal("github", manifest?.DistributionId); - Assert.Equal(0, plonds.GetLatestCalls); - Assert.Equal(1, github.GetLatestCalls); + Assert.True(report.IsUpdateAvailable); + Assert.Equal("9.9.9", report.LatestVersion); + Assert.Equal(1, plonds.FindLatestCalls); + Assert.False(orchestratorCreated); + } - update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds }; - manifest = await provider.GetLatestAsync( - UpdateSettingsValues.ChannelStable, - "windows-x64", - new Version(1, 0, 0), - CancellationToken.None); + [Fact] + public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator() + { + var settings = new FakeSettingsService + { + Snapshot = + { + UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub + } + }; + var orchestrator = CreateTestOrchestrator(DefaultUpdateState() with + { + UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub + }); + var orchestratorCreated = false; + var service = new UpdateSettingsService( + settings, + orchestratorFactory: () => + { + orchestratorCreated = true; + return orchestrator; + }, + plondsService: new FakePlondsService()); - Assert.Equal("plonds", manifest?.DistributionId); - Assert.Equal(1, plonds.GetLatestCalls); + var _ = service.CurrentPhase; + + Assert.False(orchestratorCreated); + + var report = await service.CheckAsync(CancellationToken.None); + + Assert.True(orchestratorCreated); + Assert.True(report.IsUpdateAvailable); } [Fact] @@ -177,6 +217,33 @@ public sealed class UpdateSettingsInterfaceTests LastUpdateCheckUtcMs: null, PendingUpdateSha256: null); + private static UpdateOrchestrator CreateTestOrchestrator(SettingsUpdateState state) + { + return new UpdateOrchestrator( + new FakeManifestProvider("github"), + new UpdateDownloadEngine(new FakeManifestProvider("github"), new ResumableDownloadService(new HttpClient(new EmptyHandler()))), + new UpdateInstallGateway(), + new UpdateStateStore(new FakeSettingsFacade(new FakeUpdateSettingsService { State = state }))); + } + + private static PlondsClientManifest CreatePlondsManifest(string version) + { + return new PlondsClientManifest( + FormatVersion: "2.0", + CurrentVersion: version, + PreviousVersion: "1.0.0", + IsFullUpdate: false, + RequiresCleanInstall: false, + Channel: "stable", + Platform: "windows-x64", + UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"), + FilesMap: new Dictionary(), + ChangedFilesMap: new Dictionary(), + Checksums: new Dictionary(), + Downloads: null, + Sources: []); + } + private sealed class FakeUpdateSettingsService : IUpdateSettingsService { public SettingsUpdateState State { get; set; } = DefaultUpdateState(); @@ -263,9 +330,6 @@ public sealed class UpdateSettingsInterfaceTests public Task ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default) => CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); - public Task GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default) - => Task.FromResult(null); - public Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, @@ -285,6 +349,115 @@ public sealed class UpdateSettingsInterfaceTests => Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false)); } + private sealed class FakePlondsService : IPlondsService + { + public PlondsLatestResult LatestResult { get; set; } = PlondsLatestResult.UpToDate(new Version(1, 0, 0), new Version(1, 0, 0)); + public PlondsPrepareResult PrepareResult { get; set; } = PlondsPrepareResult.FailedForUi("not prepared"); + public int FindLatestCalls { get; private set; } + public int PrepareLatestCalls { get; private set; } + + public Task FindLatestAsync(Version currentVersion, CancellationToken cancellationToken) + { + FindLatestCalls++; + return Task.FromResult(LatestResult); + } + + public Task FindAndPrepareLatestAsync(CancellationToken cancellationToken) + { + PrepareLatestCalls++; + return Task.FromResult(PrepareResult); + } + + public Task FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken) + { + PrepareLatestCalls++; + return Task.FromResult(PrepareResult); + } + } + + private sealed class FakeSettingsService : ISettingsService + { + public event EventHandler? Changed; + + public AppSettingsSnapshot Snapshot { get; init; } = new(); + + public T LoadSnapshot(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new() + { + if (typeof(T) == typeof(AppSettingsSnapshot)) + { + return (T)(object)Snapshot.Clone(); + } + + return new T(); + } + + public void SaveSnapshot( + SettingsScope scope, + T snapshot, + string? subjectId = null, + string? placementId = null, + string? sectionId = null, + IReadOnlyCollection? changedKeys = null) + { + if (snapshot is AppSettingsSnapshot appSettings) + { + CopyUpdateSettings(appSettings, Snapshot); + } + + Changed?.Invoke(this, new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys)); + } + + public T LoadSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new() + => new(); + + public void SaveSection( + SettingsScope scope, + string subjectId, + string sectionId, + T section, + string? placementId = null, + IReadOnlyCollection? changedKeys = null) + { + } + + public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) + { + } + + public T? GetValue(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null) + => default; + + public void SetValue( + SettingsScope scope, + string key, + T value, + string? subjectId = null, + string? placementId = null, + string? sectionId = null, + IReadOnlyCollection? changedKeys = null) + { + } + + public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId) + => throw new NotSupportedException(); + + private static void CopyUpdateSettings(AppSettingsSnapshot source, AppSettingsSnapshot target) + { + target.IncludePrereleaseUpdates = source.IncludePrereleaseUpdates; + target.UpdateChannel = source.UpdateChannel; + target.UpdateMode = source.UpdateMode; + target.UpdateDownloadSource = source.UpdateDownloadSource; + target.UpdateDownloadThreads = source.UpdateDownloadThreads; + target.ForceUpdateReinstall = source.ForceUpdateReinstall; + target.UseGhProxyMirror = source.UseGhProxyMirror; + target.PendingUpdateInstallerPath = source.PendingUpdateInstallerPath; + target.PendingUpdateVersion = source.PendingUpdateVersion; + target.PendingUpdatePublishedAtUtcMs = source.PendingUpdatePublishedAtUtcMs; + target.LastUpdateCheckUtcMs = source.LastUpdateCheckUtcMs; + target.PendingUpdateSha256 = source.PendingUpdateSha256; + } + } + private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider { public string ProviderName { get; } = providerName; @@ -318,6 +491,14 @@ public sealed class UpdateSettingsInterfaceTests new Dictionary()); } + private sealed class EmptyHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)); + } + } + private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService { public ISettingsService Settings => throw new NotSupportedException(); diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs index cba74d5..29383db 100644 --- a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -34,20 +34,7 @@ public sealed record UpdateCheckResult( GitHubReleaseInfo? Release, GitHubReleaseAsset? PreferredAsset, string? ErrorMessage, - bool ForceMode = false, - PlondsUpdatePayload? PlondsPayload = null); - -public sealed record PlondsUpdatePayload( - string DistributionId, - string ChannelId, - string SubChannel, - string? FileMapJson, - string? FileMapSignature, - string? FileMapJsonUrl, - string? FileMapSignatureUrl, - string? UpdateArchiveUrl = null, - string? UpdateArchiveSha256 = null, - long? UpdateArchiveSizeBytes = null); + bool ForceMode = false); public sealed record UpdateDownloadResult( bool Success, @@ -162,10 +149,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable var preferredAsset = isUpdateAvailable ? SelectPreferredInstallerAsset(release.Assets) : null; - var plondsPayload = isUpdateAvailable - ? TryResolvePlondsPayload(release) - : null; - return new UpdateCheckResult( Success: true, IsUpdateAvailable: isUpdateAvailable, @@ -173,8 +156,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable LatestVersionText: latestVersionText, Release: release, PreferredAsset: preferredAsset, - ErrorMessage: null, - PlondsPayload: plondsPayload); + ErrorMessage: null); } catch (OperationCanceledException) { @@ -239,8 +221,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable : release.TagName; var preferredAsset = SelectPreferredInstallerAsset(release.Assets); - var plondsPayload = TryResolvePlondsPayload(release); - return new UpdateCheckResult( Success: true, IsUpdateAvailable: true, @@ -249,8 +229,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable Release: release, PreferredAsset: preferredAsset, ErrorMessage: null, - ForceMode: true, - PlondsPayload: plondsPayload); + ForceMode: true); } catch (OperationCanceledException) { @@ -703,46 +682,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable return null; } - private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release) - { - if (release.Assets is null || release.Assets.Count == 0) - { - return null; - } - - var platformSuffix = GetPlatformAssetSuffix(); - var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json"); - var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig") - ?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig"); - var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip"); - if (fileMapAsset is null || signatureAsset is null || archiveAsset is null) - { - return null; - } - - var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}"; - var channelId = release.IsPrerelease - ? UpdateSettingsValues.ChannelPreview - : UpdateSettingsValues.ChannelStable; - - return new PlondsUpdatePayload( - DistributionId: distributionId, - ChannelId: channelId, - SubChannel: platformSuffix, - FileMapJson: null, - FileMapSignature: null, - FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl, - FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl, - UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl, - UpdateArchiveSha256: archiveAsset.Sha256, - UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null); - } - - private static GitHubReleaseAsset? FindAsset(IReadOnlyList assets, string assetName) - { - return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase)); - } - private static string GetPlatformAssetSuffix() { var os = OperatingSystem.IsWindows() diff --git a/LanMountainDesktop/Services/Plonds/IPlondsPackageDownloader.cs b/LanMountainDesktop/Services/Plonds/IPlondsPackageDownloader.cs new file mode 100644 index 0000000..18a636d --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/IPlondsPackageDownloader.cs @@ -0,0 +1,14 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal interface IPlondsPackageDownloader +{ + Task PrepareDeltaAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + CancellationToken cancellationToken); + + Task PrepareFullAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + CancellationToken cancellationToken); +} diff --git a/LanMountainDesktop/Services/Plonds/IPlondsService.cs b/LanMountainDesktop/Services/Plonds/IPlondsService.cs new file mode 100644 index 0000000..3ff28b7 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/IPlondsService.cs @@ -0,0 +1,10 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal interface IPlondsService +{ + Task FindLatestAsync(Version currentVersion, CancellationToken cancellationToken); + + Task FindAndPrepareLatestAsync(CancellationToken cancellationToken); + + Task FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken); +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs b/LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs new file mode 100644 index 0000000..10107cb --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs @@ -0,0 +1,25 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed record PlondsClientDownloads( + PlondsGitHubDownloads? GitHub, + PlondsS3Downloads? S3); + +internal sealed record PlondsGitHubDownloads( + string? ReleaseUrl, + string? ManifestUrl, + string? ChangedZipUrl, + string? FilesZipUrl); + +internal sealed record PlondsS3Downloads( + string? Bucket, + string? Prefix, + string? ManifestKey, + string? ManifestUrl, + string? ChangedZipKey, + string? ChangedZipUrl, + string? ChangedFolderKey, + string? ChangedFolderUrl, + string? FilesZipKey, + string? FilesZipUrl, + string? FilesFolderKey, + string? FilesFolderUrl); diff --git a/LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs b/LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs new file mode 100644 index 0000000..798f71b --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs @@ -0,0 +1,28 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed record PlondsClientManifest( + string FormatVersion, + string CurrentVersion, + string PreviousVersion, + bool IsFullUpdate, + bool RequiresCleanInstall, + string Channel, + string Platform, + DateTimeOffset UpdatedAt, + IReadOnlyDictionary FilesMap, + IReadOnlyDictionary ChangedFilesMap, + IReadOnlyDictionary Checksums, + PlondsClientDownloads? Downloads, + IReadOnlyList? Sources); + +internal sealed record PlondsClientFileEntry( + string Action, + string Hash, + long Size, + string HashAlgorithm = "sha256"); + +internal sealed record PlondsClientChangedFileEntry( + string ArchivePath, + string Hash, + long Size, + string HashAlgorithm = "sha256"); diff --git a/LanMountainDesktop/Services/Plonds/PlondsClientServiceFactory.cs b/LanMountainDesktop/Services/Plonds/PlondsClientServiceFactory.cs new file mode 100644 index 0000000..1c0f51d --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsClientServiceFactory.cs @@ -0,0 +1,51 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal static class PlondsClientServiceFactory +{ + private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL"; + private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL"; + private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json"; + private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json"; + + public static IPlondsService CreateDefault(HttpClient? httpClient = null) + { + var client = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + var dataRoot = Path.Combine(AppDataPathProvider.GetDataRoot(), "PLONDS"); + var sourceStore = new PlondsSourceStore(Path.Combine(dataRoot, "sources.json")); + var registry = new PlondsSourceRegistry(CreateBuiltInSources()); + foreach (var source in sourceStore.LoadAsync(CancellationToken.None).GetAwaiter().GetResult()) + { + registry.Add(source); + } + + var packageStore = new PlondsPackageStore(Path.Combine(dataRoot, "packages")); + return new PlondsService( + registry, + new PlondsManifestClient(client), + new PlondsDownloadPlanner(new PlondsHttpPackageDownloader(client, packageStore, new PlondsVerifier())), + sourceStore); + } + + internal static IReadOnlyList CreateBuiltInSources() + { + return + [ + new( + Id: "s3", + Kind: "s3", + ManifestUrl: ResolveManifestUrl(S3ManifestUrlEnvironmentVariable, DefaultS3ManifestUrl), + Priority: 100), + new( + Id: "github", + Kind: "github", + ManifestUrl: ResolveManifestUrl(GitHubManifestUrlEnvironmentVariable, DefaultGitHubManifestUrl), + Priority: 50) + ]; + } + + private static string ResolveManifestUrl(string environmentVariable, string fallback) + { + var value = Environment.GetEnvironmentVariable(environmentVariable); + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs b/LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs new file mode 100644 index 0000000..9b6473a --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs @@ -0,0 +1,44 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsDownloadPlanner(IPlondsPackageDownloader downloader) +{ + public async Task PrepareAsync( + PlondsManifestCandidate candidate, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(candidate); + + try + { + var deltaPackage = await downloader + .PrepareDeltaAsync(candidate.Manifest, candidate.Source, cancellationToken) + .ConfigureAwait(false); + + return PlondsPrepareResult.Prepared(deltaPackage); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception deltaError) + { + try + { + var fullPackage = await downloader + .PrepareFullAsync(candidate.Manifest, candidate.Source, cancellationToken) + .ConfigureAwait(false); + + return PlondsPrepareResult.Prepared(fullPackage); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception fullError) + { + return PlondsPrepareResult.FailedForUi( + $"PLONDS delta package failed and full package fallback also failed. Delta: {deltaError.Message}; Full: {fullError.Message}"); + } + } + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsDownloadUrlResolver.cs b/LanMountainDesktop/Services/Plonds/PlondsDownloadUrlResolver.cs new file mode 100644 index 0000000..8cc8160 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsDownloadUrlResolver.cs @@ -0,0 +1,67 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal static class PlondsDownloadUrlResolver +{ + public static IReadOnlyList Resolve( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + PlondsPackageMode mode) + { + var urls = new List(); + var sourceKind = source.Kind.Trim().ToLowerInvariant(); + + if (sourceKind is "s3") + { + AddS3(urls, manifest, mode); + } + else if (sourceKind is "github") + { + AddGitHub(urls, manifest, mode); + } + + urls.Add(DerivePackageUrl(source.ManifestUrl, mode)); + AddS3(urls, manifest, mode); + AddGitHub(urls, manifest, mode); + + return urls + .Where(url => !string.IsNullOrWhiteSpace(url)) + .Select(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null) + .OfType() + .Where(uri => uri.Scheme is "http" or "https") + .DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static void AddS3(List urls, PlondsClientManifest manifest, PlondsPackageMode mode) + { + urls.Add(mode is PlondsPackageMode.Delta + ? manifest.Downloads?.S3?.ChangedZipUrl + : manifest.Downloads?.S3?.FilesZipUrl); + } + + private static void AddGitHub(List urls, PlondsClientManifest manifest, PlondsPackageMode mode) + { + urls.Add(mode is PlondsPackageMode.Delta + ? manifest.Downloads?.GitHub?.ChangedZipUrl + : manifest.Downloads?.GitHub?.FilesZipUrl); + } + + private static string? DerivePackageUrl(string manifestUrl, PlondsPackageMode mode) + { + if (!Uri.TryCreate(manifestUrl, UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https")) + { + return null; + } + + var packageName = mode is PlondsPackageMode.Delta ? "changed.zip" : "Files.zip"; + var builder = new UriBuilder(uri); + var lastSlash = builder.Path.LastIndexOf('/'); + builder.Path = lastSlash >= 0 + ? $"{builder.Path[..(lastSlash + 1)]}{packageName}" + : packageName; + builder.Query = string.Empty; + builder.Fragment = string.Empty; + return builder.Uri.AbsoluteUri; + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsHttpPackageDownloader.cs b/LanMountainDesktop/Services/Plonds/PlondsHttpPackageDownloader.cs new file mode 100644 index 0000000..79aa3f9 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsHttpPackageDownloader.cs @@ -0,0 +1,118 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsHttpPackageDownloader( + HttpClient httpClient, + PlondsPackageStore packageStore, + PlondsVerifier verifier) : IPlondsPackageDownloader +{ + public Task PrepareDeltaAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + CancellationToken cancellationToken) + { + if (manifest.IsFullUpdate || manifest.RequiresCleanInstall) + { + throw new InvalidOperationException("PLONDS manifest requires a full package."); + } + + return PrepareAsync(manifest, source, PlondsPackageMode.Delta, cancellationToken); + } + + public Task PrepareFullAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + CancellationToken cancellationToken) + { + return PrepareAsync(manifest, source, PlondsPackageMode.Full, cancellationToken); + } + + private async Task PrepareAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + PlondsPackageMode mode, + CancellationToken cancellationToken) + { + var urls = PlondsDownloadUrlResolver.Resolve(manifest, source, mode); + if (urls.Count == 0) + { + throw new InvalidOperationException($"PLONDS manifest does not provide a {mode} package URL."); + } + + Exception? lastError = null; + foreach (var url in urls) + { + cancellationToken.ThrowIfCancellationRequested(); + + var staging = await packageStore.CreateStagingAsync(manifest, source, mode, cancellationToken).ConfigureAwait(false); + try + { + await DownloadToFileAsync(url, staging.PackageZipPath, cancellationToken).ConfigureAwait(false); + await verifier.VerifyFileAsync( + staging.PackageZipPath, + manifest.Checksums, + GetChecksumKeys(mode, url), + cancellationToken).ConfigureAwait(false); + + packageStore.ExtractPackage(staging.PackageZipPath, staging.ExtractDirectory); + return staging.ToPreparedPackage(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lastError = ex; + } + } + + throw new InvalidOperationException($"Failed to prepare PLONDS {mode} package.", lastError); + } + + private async Task DownloadToFileAsync(Uri url, string destinationPath, CancellationToken cancellationToken) + { + using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"PLONDS package download failed: {(int)response.StatusCode} {response.ReasonPhrase}"); + } + + var directory = Path.GetDirectoryName(Path.GetFullPath(destinationPath)); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var partialPath = $"{destinationPath}.partial"; + try + { + await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) + await using (var target = File.Create(partialPath)) + { + await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false); + } + + File.Move(partialPath, destinationPath, overwrite: true); + } + finally + { + if (File.Exists(partialPath)) + { + File.Delete(partialPath); + } + } + } + + private static IReadOnlyList GetChecksumKeys(PlondsPackageMode mode, Uri url) + { + var urlFileName = Path.GetFileName(url.LocalPath); + var keys = mode is PlondsPackageMode.Delta + ? new[] { "changed.zip", urlFileName } + : new[] { "Files.zip", "files.zip", "files-windows-x64.zip", urlFileName }; + + return keys + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsInstallResult.cs b/LanMountainDesktop/Services/Plonds/PlondsInstallResult.cs new file mode 100644 index 0000000..3ff8fa5 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsInstallResult.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed record PlondsInstallResult( + bool Success, + string? ErrorMessage, + string? ErrorCode = null); diff --git a/LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs b/LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs new file mode 100644 index 0000000..b3b73e7 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs @@ -0,0 +1,28 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed record PlondsLatestResult( + bool Success, + bool IsUpdateAvailable, + Version CurrentVersion, + Version? LatestVersion, + IReadOnlyList Candidates, + string? ErrorMessage) +{ + public static PlondsLatestResult Available( + Version currentVersion, + Version latestVersion, + IReadOnlyList candidates) + { + return new PlondsLatestResult(true, true, currentVersion, latestVersion, candidates, null); + } + + public static PlondsLatestResult UpToDate(Version currentVersion, Version latestVersion) + { + return new PlondsLatestResult(true, false, currentVersion, latestVersion, [], null); + } + + public static PlondsLatestResult Failed(Version currentVersion, string message) + { + return new PlondsLatestResult(false, false, currentVersion, null, [], message); + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsManifestCandidate.cs b/LanMountainDesktop/Services/Plonds/PlondsManifestCandidate.cs new file mode 100644 index 0000000..c439da6 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsManifestCandidate.cs @@ -0,0 +1,5 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed record PlondsManifestCandidate( + PlondsSourceDescriptor Source, + PlondsClientManifest Manifest); diff --git a/LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs b/LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs new file mode 100644 index 0000000..70ab766 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsManifestClient(HttpClient httpClient) +{ + public async Task GetManifestAsync(PlondsSourceDescriptor source, CancellationToken cancellationToken) + { + using var response = await httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs b/LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs new file mode 100644 index 0000000..32c2778 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs @@ -0,0 +1,53 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal static class PlondsManifestSelector +{ + public static PlondsManifestCandidate? SelectHighestVersion(IEnumerable candidates) + { + return SelectHighestVersionCandidates(candidates).FirstOrDefault(); + } + + public static IReadOnlyList SelectHighestVersionCandidates(IEnumerable candidates) + { + var usableCandidates = candidates + .Where(candidate => TryParseVersion(candidate.Manifest.CurrentVersion, out _)) + .OrderByDescending(candidate => ParseVersion(candidate.Manifest.CurrentVersion)) + .ThenByDescending(candidate => candidate.Source.Priority) + .ToArray(); + + var highest = usableCandidates.FirstOrDefault(); + if (highest is null) + { + return []; + } + + var highestVersion = ParseVersion(highest.Manifest.CurrentVersion); + return usableCandidates + .Where(candidate => ParseVersion(candidate.Manifest.CurrentVersion).CompareTo(highestVersion) == 0) + .ToArray(); + } + + public static bool TryParseVersion(string? value, out Version version) + { + version = new Version(0, 0, 0); + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (!Version.TryParse(value.Trim().TrimStart('v', 'V'), out var parsed)) + { + return false; + } + + version = parsed.Revision >= 0 + ? new Version(parsed.Major, parsed.Minor, Math.Max(0, parsed.Build), parsed.Revision) + : new Version(parsed.Major, parsed.Minor, Math.Max(0, parsed.Build)); + return true; + } + + private static Version ParseVersion(string value) + { + return TryParseVersion(value, out var version) ? version : new Version(0, 0, 0); + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs b/LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs new file mode 100644 index 0000000..c317aad --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs @@ -0,0 +1,7 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal enum PlondsPackageMode +{ + Delta, + Full +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs b/LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs new file mode 100644 index 0000000..f8bbdb6 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs @@ -0,0 +1,155 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsPackageStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly string _rootDirectory; + + public PlondsPackageStore(string rootDirectory) + { + if (string.IsNullOrWhiteSpace(rootDirectory)) + { + throw new ArgumentException("PLONDS package store root is required.", nameof(rootDirectory)); + } + + _rootDirectory = Path.GetFullPath(rootDirectory); + Directory.CreateDirectory(_rootDirectory); + } + + public async Task CreateStagingAsync( + PlondsClientManifest manifest, + PlondsSourceDescriptor source, + PlondsPackageMode mode, + CancellationToken cancellationToken) + { + if (!PlondsManifestSelector.TryParseVersion(manifest.CurrentVersion, out var version)) + { + throw new InvalidDataException($"Invalid PLONDS version: {manifest.CurrentVersion}"); + } + + var modeDirectoryName = mode is PlondsPackageMode.Delta ? "delta" : "full"; + var stagingRoot = Path.Combine( + _rootDirectory, + SanitizePathSegment(version.ToString()), + SanitizePathSegment(source.Id), + modeDirectoryName); + + EnsureCleanDirectory(stagingRoot); + + var manifestPath = Path.Combine(stagingRoot, "PLONDS.json"); + await using (var manifestStream = File.Create(manifestPath)) + { + await JsonSerializer.SerializeAsync(manifestStream, manifest, JsonOptions, cancellationToken).ConfigureAwait(false); + } + + var zipPath = Path.Combine(stagingRoot, mode is PlondsPackageMode.Delta ? "changed.zip" : "Files.zip"); + var extractDirectory = Path.Combine(stagingRoot, mode is PlondsPackageMode.Delta ? "changed" : "Files"); + Directory.CreateDirectory(extractDirectory); + + return new PlondsPackageStaging(version, mode, stagingRoot, manifestPath, zipPath, extractDirectory); + } + + public void ExtractPackage(string zipPath, string destinationDirectory) + { + var resolvedDestination = Path.GetFullPath(destinationDirectory); + EnsureStorePath(resolvedDestination); + EnsureCleanDirectory(resolvedDestination); + + using var archive = ZipFile.OpenRead(zipPath); + foreach (var entry in archive.Entries) + { + var destinationPath = Path.GetFullPath(Path.Combine(resolvedDestination, entry.FullName)); + EnsureChildPath(resolvedDestination, destinationPath); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + var directory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + entry.ExtractToFile(destinationPath, overwrite: true); + } + } + + private void EnsureCleanDirectory(string path) + { + var resolvedPath = Path.GetFullPath(path); + EnsureStorePath(resolvedPath); + if (Directory.Exists(resolvedPath)) + { + Directory.Delete(resolvedPath, recursive: true); + } + + Directory.CreateDirectory(resolvedPath); + } + + private void EnsureStorePath(string path) + { + if (!IsSameOrChildPath(_rootDirectory, path)) + { + throw new InvalidOperationException($"PLONDS staging path is outside the package store: {path}"); + } + } + + private static void EnsureChildPath(string parent, string child) + { + if (!IsSameOrChildPath(parent, child)) + { + throw new InvalidDataException($"PLONDS package entry escapes the staging directory: {child}"); + } + } + + private static bool IsSameOrChildPath(string parent, string child) + { + var resolvedParent = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var resolvedChild = Path.GetFullPath(child); + return string.Equals(resolvedParent, resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase) + || resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + private static string SanitizePathSegment(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray(); + var sanitized = new string(chars).Trim(); + return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized; + } +} + +internal sealed record PlondsPackageStaging( + Version Version, + PlondsPackageMode Mode, + string RootDirectory, + string ManifestPath, + string PackageZipPath, + string ExtractDirectory) +{ + public PlondsPreparedPackage ToPreparedPackage() + { + return new PlondsPreparedPackage( + Version, + Mode, + ManifestPath, + Mode is PlondsPackageMode.Delta ? PackageZipPath : null, + Mode is PlondsPackageMode.Delta ? ExtractDirectory : null, + Mode is PlondsPackageMode.Full ? PackageZipPath : null, + Mode is PlondsPackageMode.Full ? ExtractDirectory : null); + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs b/LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs new file mode 100644 index 0000000..b4d917a --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs @@ -0,0 +1,12 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed record PlondsPrepareResult( + bool Success, + PlondsPreparedPackage? Package, + string? ErrorMessage, + bool RequiresUiHandling) +{ + public static PlondsPrepareResult Prepared(PlondsPreparedPackage package) => new(true, package, null, false); + + public static PlondsPrepareResult FailedForUi(string message) => new(false, null, message, true); +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs b/LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs new file mode 100644 index 0000000..e56fa4b --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs @@ -0,0 +1,10 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed record PlondsPreparedPackage( + Version Version, + PlondsPackageMode Mode, + string ManifestPath, + string? ChangedZipPath, + string? ChangedDirectory, + string? FilesZipPath, + string? FilesDirectory); diff --git a/LanMountainDesktop/Services/Plonds/PlondsPreparedPackageInstaller.cs b/LanMountainDesktop/Services/Plonds/PlondsPreparedPackageInstaller.cs new file mode 100644 index 0000000..7f966e7 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsPreparedPackageInstaller.cs @@ -0,0 +1,382 @@ +using System.Security.Cryptography; +using System.Text.Json; +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsPreparedPackageInstaller +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public async Task InstallAsync( + PlondsPreparedPackage package, + string launcherRoot, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(package); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + if (package.Mode is PlondsPackageMode.Full) + { + return InstallFullPackage(package, launcherRoot, progress, cancellationToken); + } + + var manifest = await LoadManifestAsync(package.ManifestPath, cancellationToken).ConfigureAwait(false); + return InstallDeltaPackage(package, manifest, launcherRoot, progress, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + AppLogger.Warn("PLONDS.Install", $"Prepared PLONDS package install failed: {ex.Message}"); + return new PlondsInstallResult(false, ex.Message, "plonds_install_failed"); + } + } + + private static PlondsInstallResult InstallFullPackage( + PlondsPreparedPackage package, + string launcherRoot, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(package.FilesDirectory) || !Directory.Exists(package.FilesDirectory)) + { + return new PlondsInstallResult(false, "PLONDS full package directory is missing.", "staging_incomplete"); + } + + var currentDeployment = FindCurrentDeploymentDirectory(launcherRoot); + var targetDeployment = BuildNextDeploymentDirectory(launcherRoot, package.Version.ToString()); + + progress?.Report(new InstallProgressReport(InstallStage.CreateTarget, "Creating target deployment...", 15, null, 0, 0)); + PrepareTargetDirectory(targetDeployment); + CopyDirectory(package.FilesDirectory, targetDeployment, cancellationToken); + + progress?.Report(new InstallProgressReport(InstallStage.ActivateDeployment, "Activating deployment...", 85, null, 0, 0)); + ActivateDeployment(currentDeployment, targetDeployment); + progress?.Report(new InstallProgressReport(InstallStage.Completed, $"Updated to {package.Version}.", 100, null, 0, 0)); + return new PlondsInstallResult(true, null); + } + + private static PlondsInstallResult InstallDeltaPackage( + PlondsPreparedPackage package, + PlondsClientManifest manifest, + string launcherRoot, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(package.ChangedDirectory) || !Directory.Exists(package.ChangedDirectory)) + { + return new PlondsInstallResult(false, "PLONDS changed package directory is missing.", "staging_incomplete"); + } + + var currentDeployment = FindCurrentDeploymentDirectory(launcherRoot); + if (string.IsNullOrWhiteSpace(currentDeployment)) + { + return new PlondsInstallResult(false, "No current deployment was found for PLONDS delta install.", "current_missing"); + } + + var targetDeployment = BuildNextDeploymentDirectory(launcherRoot, package.Version.ToString()); + var fileEntries = manifest.FilesMap ?? new Dictionary(); + + progress?.Report(new InstallProgressReport(InstallStage.CreateTarget, "Creating target deployment...", 15, null, 0, fileEntries.Count)); + PrepareTargetDirectory(targetDeployment); + CopyDirectory(currentDeployment, targetDeployment, cancellationToken, skipMarkers: true); + + var applied = 0; + foreach (var (relativePath, entry) in fileEntries) + { + cancellationToken.ThrowIfCancellationRequested(); + ApplyDeltaEntry(relativePath, entry, manifest, package.ChangedDirectory, targetDeployment); + applied++; + progress?.Report(new InstallProgressReport( + InstallStage.ApplyFiles, + "Applying PLONDS files...", + 20 + (applied * 45 / Math.Max(1, fileEntries.Count)), + relativePath, + applied, + fileEntries.Count)); + } + + VerifyFiles(fileEntries, targetDeployment, progress, cancellationToken); + + progress?.Report(new InstallProgressReport(InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count)); + ActivateDeployment(currentDeployment, targetDeployment); + progress?.Report(new InstallProgressReport(InstallStage.Completed, $"Updated to {package.Version}.", 100, null, fileEntries.Count, fileEntries.Count)); + return new PlondsInstallResult(true, null); + } + + private static async Task LoadManifestAsync(string manifestPath, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath)) + { + throw new FileNotFoundException("PLONDS manifest is missing.", manifestPath); + } + + await using var stream = File.OpenRead(manifestPath); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidDataException("PLONDS manifest is empty or invalid."); + } + + private static void ApplyDeltaEntry( + string relativePath, + PlondsClientFileEntry entry, + PlondsClientManifest manifest, + string changedDirectory, + string targetDeployment) + { + var normalizedPath = NormalizeRelativePath(relativePath); + var targetPath = Path.GetFullPath(Path.Combine(targetDeployment, normalizedPath)); + EnsureChildPath(targetDeployment, targetPath); + + var action = string.IsNullOrWhiteSpace(entry.Action) ? "replace" : entry.Action.Trim(); + if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + } + + return; + } + + if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var archivePath = manifest.ChangedFilesMap is not null && + manifest.ChangedFilesMap.TryGetValue(relativePath, out var changedEntry) && + !string.IsNullOrWhiteSpace(changedEntry.ArchivePath) + ? changedEntry.ArchivePath + : normalizedPath; + + var sourcePath = Path.GetFullPath(Path.Combine(changedDirectory, NormalizeRelativePath(archivePath))); + EnsureChildPath(changedDirectory, sourcePath); + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException($"PLONDS changed file is missing: {archivePath}", sourcePath); + } + + var targetDirectory = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } + + File.Copy(sourcePath, targetPath, overwrite: true); + } + + private static void VerifyFiles( + IReadOnlyDictionary fileEntries, + string targetDeployment, + IProgress? progress, + CancellationToken cancellationToken) + { + var verified = 0; + foreach (var (relativePath, entry) in fileEntries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.Equals(entry.Action, "delete", StringComparison.OrdinalIgnoreCase)) + { + verified++; + continue; + } + + if (string.IsNullOrWhiteSpace(entry.Hash)) + { + verified++; + continue; + } + + var targetPath = Path.GetFullPath(Path.Combine(targetDeployment, NormalizeRelativePath(relativePath))); + EnsureChildPath(targetDeployment, targetPath); + if (!File.Exists(targetPath)) + { + throw new FileNotFoundException($"Expected PLONDS target file was not created: {relativePath}", targetPath); + } + + var actual = ComputeHash(targetPath, entry.HashAlgorithm); + if (!string.Equals(actual, entry.Hash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"PLONDS target hash mismatch for {relativePath}. Expected {entry.Hash}, actual {actual}."); + } + + verified++; + progress?.Report(new InstallProgressReport( + InstallStage.VerifyHashes, + "Verifying PLONDS files...", + 65 + (verified * 15 / Math.Max(1, fileEntries.Count)), + relativePath, + verified, + fileEntries.Count)); + } + } + + private static void PrepareTargetDirectory(string targetDeployment) + { + if (Directory.Exists(targetDeployment)) + { + Directory.Delete(targetDeployment, recursive: true); + } + + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); + } + + private static void CopyDirectory( + string sourceDirectory, + string targetDirectory, + CancellationToken cancellationToken, + bool skipMarkers = false) + { + var resolvedSource = Path.GetFullPath(sourceDirectory); + foreach (var sourcePath in Directory.EnumerateFiles(resolvedSource, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedSource, sourcePath)); + if (skipMarkers && IsDeploymentMarker(relativePath)) + { + continue; + } + + var targetPath = Path.GetFullPath(Path.Combine(targetDirectory, relativePath)); + EnsureChildPath(targetDirectory, targetPath); + var targetParent = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(targetParent)) + { + Directory.CreateDirectory(targetParent); + } + + File.Copy(sourcePath, targetPath, overwrite: true); + } + } + + private static string? FindCurrentDeploymentDirectory(string launcherRoot) + { + if (!Directory.Exists(launcherRoot)) + { + return null; + } + + var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; + return Directory.GetDirectories(launcherRoot, "app-*", SearchOption.TopDirectoryOnly) + .Where(path => !File.Exists(Path.Combine(path, ".destroy"))) + .Where(path => !File.Exists(Path.Combine(path, ".partial"))) + .Where(path => File.Exists(Path.Combine(path, executable)) || File.Exists(Path.Combine(path, ".current"))) + .Select(path => new + { + Path = path, + Version = ParseVersionFromDirectory(path), + HasCurrent = File.Exists(Path.Combine(path, ".current")) + }) + .OrderBy(x => x.HasCurrent ? 0 : 1) + .ThenByDescending(x => x.Version) + .Select(x => x.Path) + .FirstOrDefault(); + } + + private static string BuildNextDeploymentDirectory(string launcherRoot, string targetVersion) + { + Directory.CreateDirectory(launcherRoot); + var sanitized = SanitizePathSegment(targetVersion); + var index = 0; + while (true) + { + var candidate = Path.Combine(launcherRoot, $"app-{sanitized}-{index}"); + if (!Directory.Exists(candidate)) + { + return candidate; + } + + index++; + } + } + + private static void ActivateDeployment(string? currentDeployment, string targetDeployment) + { + File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty); + TryDeleteFile(Path.Combine(targetDeployment, ".partial")); + TryDeleteFile(Path.Combine(targetDeployment, ".destroy")); + + if (!string.IsNullOrWhiteSpace(currentDeployment) && Directory.Exists(currentDeployment)) + { + TryDeleteFile(Path.Combine(currentDeployment, ".current")); + File.WriteAllText(Path.Combine(currentDeployment, ".destroy"), string.Empty); + } + } + + private static string ComputeHash(string filePath, string algorithm) + { + using var stream = File.OpenRead(filePath); + var normalized = string.IsNullOrWhiteSpace(algorithm) ? "sha256" : algorithm.Trim().ToLowerInvariant(); + var hash = normalized == "md5" + ? MD5.HashData(stream) + : SHA256.HashData(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static Version ParseVersionFromDirectory(string path) + { + var fileName = Path.GetFileName(path); + var segments = fileName.Split('-'); + return segments.Length >= 2 && Version.TryParse(segments[1], out var version) + ? version + : new Version(0, 0, 0); + } + + private static void EnsureChildPath(string parent, string child) + { + var resolvedParent = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var resolvedChild = Path.GetFullPath(child); + if (!resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + !resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + !string.Equals(resolvedParent, resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"PLONDS path escapes its root: {child}"); + } + } + + private static string NormalizeRelativePath(string value) + { + return value.Replace('\\', '/').TrimStart('/'); + } + + private static bool IsDeploymentMarker(string relativePath) + { + return relativePath is ".current" or ".partial" or ".destroy"; + } + + private static string SanitizePathSegment(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); + return string.IsNullOrWhiteSpace(sanitized) ? "0.0.0" : sanitized; + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + } + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsService.cs b/LanMountainDesktop/Services/Plonds/PlondsService.cs new file mode 100644 index 0000000..e2a94e1 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsService.cs @@ -0,0 +1,107 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsService( + PlondsSourceRegistry sourceRegistry, + PlondsManifestClient manifestClient, + PlondsDownloadPlanner downloadPlanner, + PlondsSourceStore? sourceStore = null) : IPlondsService +{ + public async Task FindLatestAsync(Version currentVersion, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(currentVersion); + + var selectedCandidates = await DiscoverHighestVersionCandidatesAsync(cancellationToken).ConfigureAwait(false); + if (selectedCandidates.Count == 0) + { + return PlondsLatestResult.Failed(currentVersion, "No usable PLONDS manifest was found."); + } + + var selected = selectedCandidates[0]; + if (!PlondsManifestSelector.TryParseVersion(selected.Manifest.CurrentVersion, out var latestVersion)) + { + return PlondsLatestResult.Failed(currentVersion, $"Invalid PLONDS version: {selected.Manifest.CurrentVersion}"); + } + + return latestVersion.CompareTo(currentVersion) > 0 + ? PlondsLatestResult.Available(currentVersion, latestVersion, selectedCandidates) + : PlondsLatestResult.UpToDate(currentVersion, latestVersion); + } + + public Task FindAndPrepareLatestAsync(CancellationToken cancellationToken) + { + return FindAndPrepareLatestAsync(new Version(0, 0, 0), cancellationToken); + } + + public async Task FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken) + { + var latest = await FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false); + if (!latest.Success) + { + return PlondsPrepareResult.FailedForUi(latest.ErrorMessage ?? "No usable PLONDS manifest was found."); + } + + if (!latest.IsUpdateAvailable) + { + return PlondsPrepareResult.FailedForUi("No newer PLONDS version was found."); + } + + var errors = new List(); + foreach (var selected in latest.Candidates) + { + var result = await downloadPlanner.PrepareAsync(selected, cancellationToken).ConfigureAwait(false); + if (result.Success) + { + return result; + } + + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + errors.Add($"{selected.Source.Id}: {result.ErrorMessage}"); + } + } + + return PlondsPrepareResult.FailedForUi(string.Join(Environment.NewLine, errors)); + } + + private async Task> DiscoverHighestVersionCandidatesAsync(CancellationToken cancellationToken) + { + var candidates = new List(); + var sources = sourceRegistry.Sources.ToArray(); + + foreach (var source in sources) + { + cancellationToken.ThrowIfCancellationRequested(); + + PlondsClientManifest? manifest; + try + { + manifest = await manifestClient.GetManifestAsync(source, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + AppLogger.Warn("PLONDS.Source", $"Failed to read PLONDS manifest from source '{source.Id}'.", ex); + continue; + } + + if (manifest is null) + { + continue; + } + + var manifestSources = manifest.Sources ?? []; + sourceRegistry.AddRange(manifestSources); + if (manifestSources.Count > 0 && sourceStore is not null) + { + await sourceStore.SaveAsync(sourceRegistry.Sources, cancellationToken).ConfigureAwait(false); + } + + candidates.Add(new PlondsManifestCandidate(source, manifest)); + } + + return PlondsManifestSelector.SelectHighestVersionCandidates(candidates); + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsSourceDescriptor.cs b/LanMountainDesktop/Services/Plonds/PlondsSourceDescriptor.cs new file mode 100644 index 0000000..0becda5 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsSourceDescriptor.cs @@ -0,0 +1,7 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed record PlondsSourceDescriptor( + string Id, + string Kind, + string ManifestUrl, + int Priority = 0); diff --git a/LanMountainDesktop/Services/Plonds/PlondsSourceRegistry.cs b/LanMountainDesktop/Services/Plonds/PlondsSourceRegistry.cs new file mode 100644 index 0000000..21eabe0 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsSourceRegistry.cs @@ -0,0 +1,57 @@ +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsSourceRegistry +{ + private readonly List _sources = []; + + public PlondsSourceRegistry(IEnumerable initialSources) + { + AddRange(initialSources); + } + + public IReadOnlyList Sources => _sources; + + public void AddRange(IEnumerable? sources) + { + if (sources is null) + { + return; + } + + foreach (var source in sources) + { + Add(source); + } + } + + public void Add(PlondsSourceDescriptor source) + { + if (string.IsNullOrWhiteSpace(source.Id) || string.IsNullOrWhiteSpace(source.ManifestUrl)) + { + return; + } + + var normalized = source with + { + Id = source.Id.Trim(), + Kind = string.IsNullOrWhiteSpace(source.Kind) ? "http" : source.Kind.Trim(), + ManifestUrl = source.ManifestUrl.Trim() + }; + + var existingIndex = _sources.FindIndex(item => + string.Equals(item.Id, normalized.Id, StringComparison.OrdinalIgnoreCase)); + + if (existingIndex >= 0) + { + _sources[existingIndex] = normalized; + return; + } + + if (_sources.Any(item => string.Equals(item.ManifestUrl, normalized.ManifestUrl, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + _sources.Add(normalized); + } +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsSourceStore.cs b/LanMountainDesktop/Services/Plonds/PlondsSourceStore.cs new file mode 100644 index 0000000..385124d --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsSourceStore.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsSourceStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly string _sourceFilePath; + + public PlondsSourceStore(string sourceFilePath) + { + if (string.IsNullOrWhiteSpace(sourceFilePath)) + { + throw new ArgumentException("PLONDS source cache path is required.", nameof(sourceFilePath)); + } + + _sourceFilePath = Path.GetFullPath(sourceFilePath); + } + + public async Task> LoadAsync(CancellationToken cancellationToken) + { + if (!File.Exists(_sourceFilePath)) + { + return []; + } + + await using var stream = File.OpenRead(_sourceFilePath); + var document = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) + .ConfigureAwait(false); + + return document?.Sources ?? []; + } + + public async Task SaveAsync(IEnumerable sources, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(_sourceFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var normalized = new PlondsSourceRegistry(sources).Sources.ToArray(); + var document = new PlondsSourceStoreDocument(normalized); + await using var stream = File.Create(_sourceFilePath); + await JsonSerializer.SerializeAsync(stream, document, JsonOptions, cancellationToken).ConfigureAwait(false); + } + + private sealed record PlondsSourceStoreDocument(IReadOnlyList Sources); +} diff --git a/LanMountainDesktop/Services/Plonds/PlondsVerifier.cs b/LanMountainDesktop/Services/Plonds/PlondsVerifier.cs new file mode 100644 index 0000000..889c4f9 --- /dev/null +++ b/LanMountainDesktop/Services/Plonds/PlondsVerifier.cs @@ -0,0 +1,104 @@ +using System.Security.Cryptography; + +namespace LanMountainDesktop.Services.Plonds; + +internal sealed class PlondsVerifier +{ + public async Task VerifyFileAsync( + string filePath, + IReadOnlyDictionary? checksums, + IEnumerable checksumKeys, + CancellationToken cancellationToken) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException("PLONDS package was not downloaded.", filePath); + } + + var checksum = FindChecksum(checksums, checksumKeys); + if (checksum is null) + { + throw new InvalidDataException("PLONDS manifest does not declare a checksum for the package."); + } + + var (algorithm, expectedHash) = ParseChecksum(checksum); + var actualHash = await ComputeHashAsync(filePath, algorithm, cancellationToken).ConfigureAwait(false); + if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"PLONDS package checksum mismatch. Expected {algorithm}:{expectedHash}, actual {algorithm}:{actualHash}."); + } + } + + private static string? FindChecksum( + IReadOnlyDictionary? checksums, + IEnumerable checksumKeys) + { + if (checksums is null || checksums.Count == 0) + { + return null; + } + + foreach (var key in checksumKeys.Where(key => !string.IsNullOrWhiteSpace(key)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (checksums.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + + var match = checksums.FirstOrDefault(item => + string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(match.Value)) + { + return match.Value; + } + } + + return null; + } + + private static (string Algorithm, string Hash) ParseChecksum(string checksum) + { + var normalized = checksum.Trim(); + var separatorIndex = normalized.IndexOf(':', StringComparison.Ordinal); + if (separatorIndex > 0) + { + var algorithm = normalized[..separatorIndex].Trim().ToLowerInvariant(); + var hash = NormalizeHash(normalized[(separatorIndex + 1)..]); + if (algorithm is "md5" or "sha256" && hash.Length > 0) + { + return (algorithm, hash); + } + } + + var inferredHash = NormalizeHash(normalized); + return inferredHash.Length switch + { + 32 => ("md5", inferredHash), + 64 => ("sha256", inferredHash), + _ => throw new InvalidDataException($"Unsupported PLONDS checksum format: {checksum}") + }; + } + + private static async Task ComputeHashAsync( + string filePath, + string algorithm, + CancellationToken cancellationToken) + { + using HashAlgorithm hasher = algorithm switch + { + "md5" => MD5.Create(), + "sha256" => SHA256.Create(), + _ => throw new InvalidDataException($"Unsupported PLONDS checksum algorithm: {algorithm}") + }; + + await using var stream = File.OpenRead(filePath); + var hash = await hasher.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string NormalizeHash(string value) + { + return value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); + } +} diff --git a/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs b/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs deleted file mode 100644 index a22e60b..0000000 --- a/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace LanMountainDesktop.Services; - -/// -/// Release-backed PLONDS checker. -/// It only succeeds when the latest GitHub Release already exposes platform PLONDS assets. -/// If those assets are not ready yet, callers can fall back to the normal GitHub installer flow. -/// -public sealed class PlondsReleaseUpdateService : IDisposable -{ - private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop"); - - public Task CheckForUpdatesAsync( - Version currentVersion, - bool includePrerelease, - CancellationToken cancellationToken = default) - { - return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken); - } - - public Task ForceCheckForUpdatesAsync( - Version currentVersion, - bool includePrerelease, - CancellationToken cancellationToken = default) - { - return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); - } - - public void Dispose() - { - _githubReleaseUpdateService.Dispose(); - } - - private async Task CheckForUpdatesCoreAsync( - Version currentVersion, - bool includePrerelease, - bool isForce, - CancellationToken cancellationToken) - { - var releaseResult = isForce - ? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) - : await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); - - if (!releaseResult.Success) - { - return releaseResult; - } - - if (!isForce && !releaseResult.IsUpdateAvailable) - { - return releaseResult with { ForceMode = false }; - } - - if (releaseResult.PlondsPayload is not null) - { - return releaseResult with { ForceMode = isForce }; - } - - var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText) - ? "-" - : releaseResult.LatestVersionText; - var message = releaseResult.Release is null - ? "GitHub Release data is unavailable for PLONDS." - : $"Release {latestVersion} does not expose platform PLONDS assets yet."; - - return new UpdateCheckResult( - Success: false, - IsUpdateAvailable: releaseResult.IsUpdateAvailable, - CurrentVersionText: releaseResult.CurrentVersionText, - LatestVersionText: latestVersion, - Release: releaseResult.Release, - PreferredAsset: releaseResult.PreferredAsset, - ErrorMessage: message, - ForceMode: isForce, - PlondsPayload: null); - } -} diff --git a/LanMountainDesktop/Services/PlondsStaticUpdateService.cs b/LanMountainDesktop/Services/PlondsStaticUpdateService.cs deleted file mode 100644 index 9af5ce9..0000000 --- a/LanMountainDesktop/Services/PlondsStaticUpdateService.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System.Net.Http; -using System.Runtime.InteropServices; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace LanMountainDesktop.Services; - -internal sealed class PlondsStaticUpdateService : IDisposable -{ - private readonly HttpClient _httpClient; - private readonly bool _ownsHttpClient; - private readonly string _baseUrl; - - public PlondsStaticUpdateService(string? baseUrl = null, HttpClient? httpClient = null) - { - _baseUrl = NormalizeBaseUrl(baseUrl ?? ResolveConfiguredBaseUrl()); - if (httpClient is null) - { - _httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(30) - }; - _ownsHttpClient = true; - } - else - { - _httpClient = httpClient; - _ownsHttpClient = false; - } - - if (!_httpClient.DefaultRequestHeaders.UserAgent.Any()) - { - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0"); - } - } - - public Task CheckForUpdatesAsync( - Version currentVersion, - bool includePrerelease, - CancellationToken cancellationToken = default) - { - return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken); - } - - public Task ForceCheckForUpdatesAsync( - Version currentVersion, - bool includePrerelease, - CancellationToken cancellationToken = default) - { - return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); - } - - public void Dispose() - { - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } - } - - internal static string ResolveCurrentPlatform() - { - var os = OperatingSystem.IsWindows() - ? "windows" - : OperatingSystem.IsLinux() - ? "linux" - : OperatingSystem.IsMacOS() - ? "macos" - : "unknown"; - - var arch = RuntimeInformation.OSArchitecture switch - { - Architecture.X86 => "x86", - Architecture.Arm => "arm", - Architecture.Arm64 => "arm64", - _ => "x64" - }; - - return $"{os}-{arch}"; - } - - private async Task CheckForUpdatesCoreAsync( - Version currentVersion, - bool includePrerelease, - bool isForce, - CancellationToken cancellationToken) - { - var currentVersionText = FormatVersion(currentVersion); - var channel = includePrerelease ? UpdateSettingsValues.ChannelPreview : UpdateSettingsValues.ChannelStable; - var platform = ResolveCurrentPlatform(); - - try - { - var latestUrl = BuildUrl($"meta/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest.json"); - var latest = await GetJsonAsync(latestUrl, cancellationToken); - if (latest is null || string.IsNullOrWhiteSpace(latest.DistributionId)) - { - return Failed(currentVersionText, isForce, $"PLONDS static latest manifest is unavailable at {latestUrl}."); - } - - var distributionUrl = BuildUrl($"meta/distributions/{Uri.EscapeDataString(latest.DistributionId)}.json"); - var distribution = await GetJsonAsync(distributionUrl, cancellationToken); - if (distribution is null) - { - return Failed(currentVersionText, isForce, $"PLONDS static distribution manifest is unavailable at {distributionUrl}."); - } - - var latestVersionText = FirstNonEmpty(distribution.Version, latest.Version) ?? "-"; - var isNewer = TryParseVersion(latestVersionText, out var latestVersion) && latestVersion > currentVersion; - var isUpdateAvailable = isForce || isNewer; - var payload = isUpdateAvailable - ? CreatePayload(distribution, latest, channel, platform) - : null; - - return new UpdateCheckResult( - Success: true, - IsUpdateAvailable: isUpdateAvailable, - CurrentVersionText: currentVersionText, - LatestVersionText: latestVersionText, - Release: null, - PreferredAsset: null, - ErrorMessage: null, - ForceMode: isForce, - PlondsPayload: payload); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return Failed(currentVersionText, isForce, ex.Message); - } - } - - private PlondsUpdatePayload CreatePayload( - DistributionDto distribution, - LatestPointerDto latest, - string channel, - string platform) - { - var distributionId = FirstNonEmpty(distribution.DistributionId, latest.DistributionId) ?? string.Empty; - var fileMapUrl = FirstNonEmpty(distribution.FileMapUrl, BuildUrl($"manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json")); - var signatureUrl = FirstNonEmpty(distribution.FileMapSignatureUrl, fileMapUrl + ".sig"); - - return new PlondsUpdatePayload( - DistributionId: distributionId, - ChannelId: FirstNonEmpty(distribution.Channel, latest.Channel, channel) ?? channel, - SubChannel: FirstNonEmpty(distribution.Platform, latest.Platform, platform) ?? platform, - FileMapJson: null, - FileMapSignature: null, - FileMapJsonUrl: fileMapUrl, - FileMapSignatureUrl: signatureUrl); - } - - private async Task GetJsonAsync(string url, CancellationToken cancellationToken) - { - using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return default; - } - - if (!response.IsSuccessStatusCode) - { - var body = await response.Content.ReadAsStringAsync(cancellationToken); - throw new InvalidOperationException($"HTTP {(int)response.StatusCode} from {url}: {Truncate(body, 256)}"); - } - - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); - } - - private static UpdateCheckResult Failed(string currentVersionText, bool isForce, string message) - { - return new UpdateCheckResult( - Success: false, - IsUpdateAvailable: false, - CurrentVersionText: currentVersionText, - LatestVersionText: "-", - Release: null, - PreferredAsset: null, - ErrorMessage: message, - ForceMode: isForce); - } - - private string BuildUrl(string relativePath) - { - return $"{_baseUrl}/{relativePath.TrimStart('/')}"; - } - - private static string ResolveConfiguredBaseUrl() - { - var environmentValue = Environment.GetEnvironmentVariable(UpdateSettingsValues.PlondsStaticBaseUrlEnvironmentVariable); - return string.IsNullOrWhiteSpace(environmentValue) - ? UpdateSettingsValues.DefaultPlondsStaticBaseUrl - : environmentValue; - } - - private static string NormalizeBaseUrl(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return UpdateSettingsValues.DefaultPlondsStaticBaseUrl; - } - - return value.Trim().TrimEnd('/'); - } - - private static bool TryParseVersion(string? value, out Version version) - { - version = new Version(0, 0, 0); - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - if (!Version.TryParse(value.Trim().TrimStart('v', 'V'), out var parsed)) - { - return false; - } - - version = parsed; - return true; - } - - private static string FormatVersion(Version version) - { - if (version.Revision >= 0) - { - return version.ToString(); - } - - return version.Build >= 0 - ? $"{version.Major}.{version.Minor}.{version.Build}" - : $"{version.Major}.{version.Minor}"; - } - - private static string? FirstNonEmpty(params string?[] values) - { - return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); - } - - private static string Truncate(string value, int maxLength) - { - if (string.IsNullOrEmpty(value) || value.Length <= maxLength) - { - return value; - } - - return value[..maxLength]; - } - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true - }; - - private sealed record LatestPointerDto( - string? DistributionId, - string? Version, - string? Channel, - string? Platform, - DateTimeOffset PublishedAt); - - private sealed record DistributionDto( - string? DistributionId, - string? Version, - string? SourceVersion, - string? Channel, - string? Platform, - DateTimeOffset PublishedAt, - string? FileMapUrl, - string? FileMapSignatureUrl); -} diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 69484cb..f25d892 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -375,7 +375,6 @@ public interface IUpdateSettingsService bool TryApplyOnExit(); Task CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); Task ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); - Task GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default); Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 5e2ba6f..db547e5 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -10,6 +10,7 @@ using Avalonia.Media.Imaging; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Plonds; using LanMountainDesktop.Services.Update; using LanMountainDesktop.Settings.Core; using LanMountainDesktop.Services.PluginMarket; @@ -788,44 +789,57 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl { private readonly ISettingsService _settingsService; private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop"); - private readonly PlondsStaticUpdateService _plondsStaticUpdateService = new(); - private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new(); + private readonly IPlondsService _plondsService; + private readonly PlondsPreparedPackageInstaller _plondsInstaller = new(); private readonly Lazy _orchestrator; + private PlondsLatestResult? _pendingPlondsLatest; + private PlondsPreparedPackage? _pendingPlondsPackage; + private UpdatePhase _plondsPhase = UpdatePhase.Idle; + private bool _orchestratorEventsSubscribed; - public UpdateSettingsService(ISettingsService settingsService, Func? orchestratorFactory = null) + public UpdateSettingsService( + ISettingsService settingsService, + Func? orchestratorFactory = null, + IPlondsService? plondsService = null) { _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + _plondsService = plondsService ?? PlondsClientServiceFactory.CreateDefault(); _orchestrator = new Lazy( orchestratorFactory ?? HostUpdateOrchestratorProvider.GetOrCreate, LazyThreadSafetyMode.ExecutionAndPublication); } - public UpdatePhase CurrentPhase => _orchestrator.Value.CurrentPhase; + public UpdatePhase CurrentPhase => IsPlondsSelected() + ? _plondsPhase + : (_orchestrator.IsValueCreated ? _orchestrator.Value.CurrentPhase : UpdatePhase.Idle); public event Action? PhaseChanged { - add => _orchestrator.Value.PhaseChanged += value; + add + { + _phaseChanged += value; + } remove { - if (_orchestrator.IsValueCreated) - { - _orchestrator.Value.PhaseChanged -= value; - } + _phaseChanged -= value; } } public event Action? ProgressChanged { - add => _orchestrator.Value.ProgressChanged += value; + add + { + _progressChanged += value; + } remove { - if (_orchestrator.IsValueCreated) - { - _orchestrator.Value.ProgressChanged -= value; - } + _progressChanged -= value; } } + private event Action? _phaseChanged; + private event Action? _progressChanged; + public UpdateSettingsState Get() { var snapshot = _settingsService.Load(); @@ -900,47 +914,75 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl public Task CheckAsync(CancellationToken cancellationToken = default) { - return _orchestrator.Value.CheckAsync(cancellationToken); + return IsPlondsSelected() + ? CheckPlondsAsync(cancellationToken) + : GetOrchestrator().CheckAsync(cancellationToken); } public Task DownloadAsync(CancellationToken cancellationToken = default) { - return _orchestrator.Value.DownloadAsync(cancellationToken); + return IsPlondsSelected() + ? DownloadPlondsAsync(cancellationToken) + : GetOrchestrator().DownloadAsync(cancellationToken); } public Task InstallAsync(CancellationToken cancellationToken = default) { - return _orchestrator.Value.InstallAsync(cancellationToken); + return IsPlondsSelected() + ? InstallPlondsAsync(cancellationToken) + : GetOrchestrator().InstallAsync(cancellationToken); } public Task RollbackAsync(CancellationToken cancellationToken = default) { - return _orchestrator.Value.RollbackAsync(cancellationToken); + return GetOrchestrator().RollbackAsync(cancellationToken); } public Task PauseAsync() { - return _orchestrator.Value.PauseAsync(); + return IsPlondsSelected() + ? PausePlondsAsync() + : GetOrchestrator().PauseAsync(); } public Task ResumeAsync(CancellationToken cancellationToken = default) { - return _orchestrator.Value.ResumeAsync(cancellationToken); + return IsPlondsSelected() + ? ResumePlondsAsync(cancellationToken) + : GetOrchestrator().ResumeAsync(cancellationToken); } public Task CancelAsync() { - return _orchestrator.Value.CancelAsync(); + if (IsPlondsSelected()) + { + _pendingPlondsLatest = null; + _pendingPlondsPackage = null; + TransitionPlonds(UpdatePhase.Idle); + return Task.CompletedTask; + } + + return GetOrchestrator().CancelAsync(); } public Task AutoCheckIfEnabledAsync(CancellationToken cancellationToken = default) { - return _orchestrator.Value.AutoCheckIfEnabledAsync(cancellationToken); + if (IsPlondsSelected()) + { + return AutoCheckPlondsIfEnabledAsync(cancellationToken); + } + + return GetOrchestrator().AutoCheckIfEnabledAsync(cancellationToken); } public bool TryApplyOnExit() { - return _orchestrator.Value.TryApplyOnExit(); + if (IsPlondsSelected()) + { + return false; + } + + return GetOrchestrator().TryApplyOnExit(); } public Task CheckForUpdatesAsync( @@ -959,26 +1001,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); } - public async Task GetPlondsUpdatePayloadAsync( - Version currentVersion, - bool includePrerelease, - bool isForce = false, - CancellationToken cancellationToken = default) - { - var staticResult = isForce - ? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) - : await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); - if (staticResult.Success && staticResult.PlondsPayload is not null) - { - return staticResult.PlondsPayload; - } - - var result = isForce - ? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) - : await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); - return result.Success ? result.PlondsPayload : null; - } - public Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, @@ -1016,8 +1038,11 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl public void Dispose() { _githubReleaseUpdateService.Dispose(); - _plondsStaticUpdateService.Dispose(); - _plondsReleaseUpdateService.Dispose(); + if (_orchestrator.IsValueCreated && _orchestratorEventsSubscribed) + { + _orchestrator.Value.PhaseChanged -= OnOrchestratorPhaseChanged; + _orchestrator.Value.ProgressChanged -= OnOrchestratorProgressChanged; + } } private async Task CheckForUpdatesCoreAsync( @@ -1026,59 +1051,240 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl bool isForce, CancellationToken cancellationToken) { - var source = UpdateSettingsValues.NormalizeDownloadSource(Get().UpdateDownloadSource); - if (string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) || - string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)) + if (IsGitHubSelected()) { return isForce ? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) : await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); } - var staticResult = isForce - ? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) - : await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); + var result = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false); + return new UpdateCheckResult( + Success: result.Success, + IsUpdateAvailable: isForce || result.IsUpdateAvailable, + CurrentVersionText: currentVersion.ToString(), + LatestVersionText: result.LatestVersion?.ToString() ?? "-", + Release: null, + PreferredAsset: null, + ErrorMessage: result.ErrorMessage, + ForceMode: isForce); + } - if (staticResult.Success) + private async Task CheckPlondsAsync(CancellationToken cancellationToken) + { + if (!_plondsPhase.CanCheck()) { - return staticResult; + return new UpdateCheckReport(false, null, null, null, null, null, null, null, null, $"Cannot check in phase {_plondsPhase}."); } - AppLogger.Warn( - "UpdateSettings", - $"PLONDS static update check failed and will fallback to GitHub release PLONDS. Error: {staticResult.ErrorMessage}"); - - var plondsResult = isForce - ? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) - : await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); - - if (plondsResult.Success) + TransitionPlonds(UpdatePhase.Checking); + var currentVersionText = LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider.ResolveForCurrentProcess().Version; + if (!TryParseVersion(currentVersionText, out var currentVersion)) { - return plondsResult; + TransitionPlonds(UpdatePhase.Failed); + return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, $"Invalid current version text: {currentVersionText}"); } - AppLogger.Warn( - "UpdateSettings", - $"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}"); + var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false); + _pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null; + _pendingPlondsPackage = null; + TransitionPlonds(UpdatePhase.Checked); + SaveLastChecked(); - var githubFallbackResult = isForce - ? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) - : await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); - - if (githubFallbackResult.Success) + if (!latest.Success) { - AppLogger.Info( - "UpdateSettings", - $"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}"); - } - else - { - AppLogger.Warn( - "UpdateSettings", - $"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}"); + return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage); } - return githubFallbackResult; + return new UpdateCheckReport( + latest.IsUpdateAvailable, + latest.LatestVersion?.ToString(), + currentVersionText, + latest.IsUpdateAvailable ? UpdatePayloadKind.DeltaPlonds : null, + latest.Candidates.FirstOrDefault()?.Source.Id, + Get().UpdateChannel, + DateTimeOffset.UtcNow, + null, + null, + null); + } + + private async Task DownloadPlondsAsync(CancellationToken cancellationToken) + { + if (_plondsPhase is not UpdatePhase.Checked) + { + return new LanMountainDesktop.Services.Update.DownloadResult(false, null, $"Cannot download in phase {_plondsPhase}.", false); + } + + if (_pendingPlondsLatest is null || !_pendingPlondsLatest.IsUpdateAvailable) + { + return new LanMountainDesktop.Services.Update.DownloadResult(false, null, "No PLONDS update is pending.", false); + } + + TransitionPlonds(UpdatePhase.Downloading); + var currentVersion = _pendingPlondsLatest.CurrentVersion; + var result = await _plondsService.FindAndPrepareLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false); + if (!result.Success || result.Package is null) + { + TransitionPlonds(UpdatePhase.Failed); + return new LanMountainDesktop.Services.Update.DownloadResult(false, null, result.ErrorMessage ?? "PLONDS package preparation failed.", false); + } + + _pendingPlondsPackage = result.Package; + TransitionPlonds(UpdatePhase.Downloaded); + SavePendingPlondsPackage(result.Package); + return new LanMountainDesktop.Services.Update.DownloadResult(true, result.Package.ManifestPath, null, true); + } + + private Task PausePlondsAsync() + { + if (_plondsPhase.CanPause()) + { + TransitionPlonds(UpdatePhase.PausedDownloading); + } + + return Task.CompletedTask; + } + + private async Task ResumePlondsAsync(CancellationToken cancellationToken) + { + return _plondsPhase is UpdatePhase.PausedDownloading + ? await DownloadPlondsAsync(cancellationToken).ConfigureAwait(false) + : new LanMountainDesktop.Services.Update.DownloadResult(false, null, $"Cannot resume in phase {_plondsPhase}.", false); + } + + private async Task InstallPlondsAsync(CancellationToken cancellationToken) + { + if (!_plondsPhase.CanInstall()) + { + return new InstallResult(false, $"Cannot install in phase {_plondsPhase}.", false, "invalid_phase"); + } + + if (_pendingPlondsPackage is null) + { + return new InstallResult(false, "No PLONDS package has been prepared.", false, "staging_incomplete"); + } + + TransitionPlonds(UpdatePhase.Installing); + var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory); + var progress = new Progress(report => + { + _progressChanged?.Invoke(new UpdateProgressReport( + UpdatePhase.Installing, + report.Message, + report.ProgressPercent / 100.0, + null, + report)); + }); + + var install = await _plondsInstaller.InstallAsync(_pendingPlondsPackage, launcherRoot, progress, cancellationToken).ConfigureAwait(false); + if (!install.Success) + { + TransitionPlonds(UpdatePhase.Failed); + return new InstallResult(false, install.ErrorMessage, false, install.ErrorCode); + } + + TransitionPlonds(UpdatePhase.Installed); + return new InstallResult(true, null, false); + } + + private async Task AutoCheckPlondsIfEnabledAsync(CancellationToken cancellationToken) + { + var settings = Get(); + if (string.Equals(UpdateSettingsValues.NormalizeMode(settings.UpdateMode), UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var report = await CheckPlondsAsync(cancellationToken).ConfigureAwait(false); + if (report.IsUpdateAvailable && _plondsPhase.CanDownload()) + { + await DownloadPlondsAsync(cancellationToken).ConfigureAwait(false); + } + } + + private bool IsPlondsSelected() + { + return !IsGitHubSelected(); + } + + private bool IsGitHubSelected() + { + var source = UpdateSettingsValues.NormalizeDownloadSource(Get().UpdateDownloadSource); + return string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) || + string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase); + } + + private void TransitionPlonds(UpdatePhase phase) + { + if (_plondsPhase == phase) + { + return; + } + + _plondsPhase = phase; + _phaseChanged?.Invoke(phase); + _progressChanged?.Invoke(new UpdateProgressReport(phase, $"Phase changed to {phase}", 0, null, null)); + } + + private UpdateOrchestrator GetOrchestrator() + { + var orchestrator = _orchestrator.Value; + if (!_orchestratorEventsSubscribed) + { + orchestrator.PhaseChanged += OnOrchestratorPhaseChanged; + orchestrator.ProgressChanged += OnOrchestratorProgressChanged; + _orchestratorEventsSubscribed = true; + } + + return orchestrator; + } + + private void OnOrchestratorPhaseChanged(UpdatePhase phase) + { + _phaseChanged?.Invoke(phase); + } + + private void OnOrchestratorProgressChanged(UpdateProgressReport report) + { + _progressChanged?.Invoke(report); + } + + private void SaveLastChecked() + { + var state = Get(); + Save(state with { LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + } + + private void SavePendingPlondsPackage(PlondsPreparedPackage package) + { + var state = Get(); + Save(state with + { + PendingUpdateInstallerPath = package.ManifestPath, + PendingUpdateVersion = package.Version.ToString(), + PendingUpdatePublishedAtUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + PendingUpdateSha256 = null, + LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }); + } + + private static bool TryParseVersion(string? value, out Version version) + { + version = new Version(0, 0, 0); + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Trim().TrimStart('v', 'V'); + var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']); + if (separatorIndex > 0) + { + normalized = normalized[..separatorIndex]; + } + + return Version.TryParse(normalized, out version!); } } diff --git a/LanMountainDesktop/Services/Update/GithubReleaseManifestProvider.cs b/LanMountainDesktop/Services/Update/GithubReleaseManifestProvider.cs index 132489f..ba8e68a 100644 --- a/LanMountainDesktop/Services/Update/GithubReleaseManifestProvider.cs +++ b/LanMountainDesktop/Services/Update/GithubReleaseManifestProvider.cs @@ -2,7 +2,7 @@ using LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Services.Update; -internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider +internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider, IDisposable { private readonly GitHubReleaseUpdateService _githubService; private readonly bool _ownsService; @@ -37,7 +37,7 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider return null; } - return UpdateManifestMapper.FromGitHubRelease(result.Release, result.PlondsPayload, channel, platform); + return UpdateManifestMapper.FromGitHubRelease(result.Release, channel, platform); } public async Task GetByVersionAsync( @@ -53,8 +53,7 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider return null; } - var plondsPayload = TryResolvePlondsPayload(release); - return UpdateManifestMapper.FromGitHubRelease(release, plondsPayload, channel, platform); + return UpdateManifestMapper.FromGitHubRelease(release, channel, platform); } public Task> GetIncrementalChainAsync( @@ -67,65 +66,11 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider return Task.FromResult>([]); } - private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release) + public void Dispose() { - if (release.Assets is null || release.Assets.Count == 0) + if (_ownsService) { - return null; + _githubService.Dispose(); } - - var platformSuffix = GetPlatformAssetSuffix(); - var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json"); - var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig") - ?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig"); - var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip"); - - if (fileMapAsset is null || signatureAsset is null || archiveAsset is null) - { - return null; - } - - var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}"; - var channelId = release.IsPrerelease - ? UpdateSettingsValues.ChannelPreview - : UpdateSettingsValues.ChannelStable; - - return new PlondsUpdatePayload( - DistributionId: distributionId, - ChannelId: channelId, - SubChannel: platformSuffix, - FileMapJson: null, - FileMapSignature: null, - FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl, - FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl, - UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl, - UpdateArchiveSha256: archiveAsset.Sha256, - UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null); - } - - private static GitHubReleaseAsset? FindAsset(IReadOnlyList assets, string assetName) - { - return assets.FirstOrDefault(a => string.Equals(a.Name, assetName, StringComparison.OrdinalIgnoreCase)); - } - - private static string GetPlatformAssetSuffix() - { - var os = OperatingSystem.IsWindows() - ? "windows" - : OperatingSystem.IsLinux() - ? "linux" - : OperatingSystem.IsMacOS() - ? "macos" - : "unknown"; - - var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch - { - System.Runtime.InteropServices.Architecture.X86 => "x86", - System.Runtime.InteropServices.Architecture.Arm => "arm", - System.Runtime.InteropServices.Architecture.Arm64 => "arm64", - _ => "x64" - }; - - return $"{os}-{arch}"; } } diff --git a/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs b/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs deleted file mode 100644 index 179552a..0000000 --- a/LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System.Globalization; -using System.Net.Http; -using System.Text.Json; -using System.Text.Json.Serialization; -using LanMountainDesktop.Shared.Contracts.Update; - -namespace LanMountainDesktop.Services.Update; - -internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider, IDisposable -{ - private const string ApiBasePath = "/api/plonds/v1"; - - private readonly HttpClient _httpClient; - private readonly bool _ownsHttpClient; - - public string ProviderName => "plonds-api"; - - public PlondsApiManifestProvider(string baseUrl, HttpClient? httpClient = null) - { - if (httpClient is null) - { - _httpClient = new HttpClient - { - BaseAddress = new Uri(baseUrl.TrimEnd('/')), - Timeout = TimeSpan.FromSeconds(30) - }; - _ownsHttpClient = true; - } - else - { - _httpClient = httpClient; - _httpClient.BaseAddress ??= new Uri(baseUrl.TrimEnd('/')); - _ownsHttpClient = false; - } - - if (!_httpClient.DefaultRequestHeaders.UserAgent.Any()) - { - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0"); - } - } - - public async Task GetLatestAsync( - string channel, - string platform, - Version currentVersion, - CancellationToken ct) - { - var pointer = await GetChannelPointerAsync(channel, platform, currentVersion, ct); - if (pointer is null) - { - return null; - } - - if (string.IsNullOrWhiteSpace(pointer.DistributionId) || - string.IsNullOrWhiteSpace(pointer.Version)) - { - return null; - } - - return await FetchDistributionManifestAsync(pointer.DistributionId, pointer.Version, channel, platform, ct); - } - - public async Task GetByVersionAsync( - string version, - string channel, - string platform, - CancellationToken ct) - { - var distributionId = $"{channel}-{platform}-{version}"; - return await FetchDistributionManifestAsync(distributionId, version, channel, platform, ct); - } - - public Task> GetIncrementalChainAsync( - string channel, - string platform, - Version fromVersion, - Version toVersion, - CancellationToken ct) - { - return Task.FromResult>([]); - } - - public void Dispose() - { - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } - } - - private async Task GetChannelPointerAsync( - string channel, - string platform, - Version currentVersion, - CancellationToken ct) - { - var url = $"{ApiBasePath}/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(currentVersion.ToString())}"; - - using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); - - if (response.StatusCode == System.Net.HttpStatusCode.NoContent) - { - return null; - } - - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(ct); - AppLogger.Warn("Update", $"PLONDS API latest endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}"); - return null; - } - - var json = await response.Content.ReadAsStringAsync(ct); - return JsonSerializer.Deserialize(json, PlondsJsonOptions); - } - - private async Task FetchDistributionManifestAsync( - string distributionId, - string targetVersion, - string channel, - string platform, - CancellationToken ct) - { - var url = $"{ApiBasePath}/distributions/{Uri.EscapeDataString(distributionId)}"; - - using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); - - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(ct); - AppLogger.Warn("Update", $"PLONDS API distribution endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}"); - return null; - } - - var json = await response.Content.ReadAsStringAsync(ct); - var dto = JsonSerializer.Deserialize(json, PlondsJsonOptions); - if (dto is null) - { - return null; - } - - return MapDistribution(dto, channel, platform); - } - - private static UpdateManifest MapDistribution(PlondsDistributionDto dto, string channel, string platform) - { - var files = new List(); - if (dto.Components is not null) - { - foreach (var component in dto.Components) - { - if (component.Files is null) - { - continue; - } - - foreach (var f in component.Files) - { - var action = FirstNonEmpty(f.Action, f.Op) ?? "add"; - var sha256 = FirstNonEmpty(f.Sha256, f.ContentHash) ?? string.Empty; - files.Add(new UpdateFileEntry( - Path: f.Path ?? string.Empty, - Action: action, - Sha256: sha256, - Size: f.Size, - Mode: f.Mode ?? "file-object", - ObjectKey: f.ObjectKey, - ObjectUrl: f.ObjectUrl, - ArchiveSha256: f.ArchiveSha256, - Metadata: null)); - } - } - } - - var mirrors = dto.InstallerMirrors?.Select(m => new UpdateMirrorAsset( - Platform: m.Platform ?? platform, - Url: m.Url, - Name: m.FileName, - Sha256: m.Sha256, - Size: m.Size)).ToArray(); - - var fileMapSignatureUrl = FirstNonEmpty(dto.FileMapSignatureUrl, dto.Signatures?.FirstOrDefault()?.Signature); - - return new UpdateManifest( - DistributionId: dto.DistributionId ?? string.Empty, - FromVersion: dto.SourceVersion ?? string.Empty, - ToVersion: dto.Version ?? string.Empty, - Platform: platform, - Channel: channel, - PublishedAt: dto.PublishedAt, - Kind: UpdatePayloadKind.DeltaPlonds, - FileMapUrl: dto.FileMapUrl, - FileMapSignatureUrl: fileMapSignatureUrl, - FileMapSha256: null, - Files: files, - InstallerMirrors: mirrors, - Metadata: dto.Metadata as IReadOnlyDictionary ?? new Dictionary()); - } - - private static string Truncate(string value, int maxLength) - { - if (string.IsNullOrEmpty(value) || value.Length <= maxLength) - { - return value; - } - - return value[..maxLength]; - } - - private static readonly JsonSerializerOptions PlondsJsonOptions = new() - { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true - }; - - private sealed record PlondsChannelPointerDto( - string? Channel, - string? Platform, - string? DistributionId, - string? Version, - DateTimeOffset PublishedAt); - - private sealed record PlondsDistributionDto( - string? DistributionId, - string? Version, - string? SourceVersion, - string? Channel, - string? Platform, - DateTimeOffset PublishedAt, - string? FileMapUrl, - string? FileMapSignatureUrl, - List? Components, - List? InstallerMirrors, - List? Signatures, - Dictionary? Metadata); - - private sealed record PlondsComponentDto( - string? Id, - string? Root, - string? Mode, - List? Files); - - private sealed record PlondsFileDto( - string? Path, - string? Op, - string? Action, - string? ContentHash, - string? Sha256, - long Size, - string? Mode, - string? ObjectKey, - string? ObjectUrl, - string? ArchiveSha256); - - private sealed record PlondsMirrorDto( - string? Platform, - string? Url, - string? FileName, - string? Sha256, - long Size); - - private sealed record PlondsSignatureDto( - string? Algorithm, - string? KeyId, - string? Signature); - - private static string? FirstNonEmpty(params string?[] values) - { - return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim(); - } -} diff --git a/LanMountainDesktop/Services/Update/SettingsUpdateManifestProvider.cs b/LanMountainDesktop/Services/Update/SettingsUpdateManifestProvider.cs deleted file mode 100644 index 34a964c..0000000 --- a/LanMountainDesktop/Services/Update/SettingsUpdateManifestProvider.cs +++ /dev/null @@ -1,60 +0,0 @@ -using LanMountainDesktop.Services.Settings; -using LanMountainDesktop.Shared.Contracts.Update; - -namespace LanMountainDesktop.Services.Update; - -internal sealed class SettingsUpdateManifestProvider : IUpdateManifestProvider -{ - private readonly ISettingsFacadeService _settingsFacade; - private readonly IUpdateManifestProvider _plondsWithFallback; - private readonly IUpdateManifestProvider _github; - - public SettingsUpdateManifestProvider( - ISettingsFacadeService settingsFacade, - IUpdateManifestProvider plonds, - IUpdateManifestProvider github) - { - _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); - _github = github ?? throw new ArgumentNullException(nameof(github)); - _plondsWithFallback = new CompositeManifestProvider(plonds ?? throw new ArgumentNullException(nameof(plonds)), _github); - } - - public string ProviderName => "settings-selected-update-source"; - - public Task GetLatestAsync( - string channel, - string platform, - Version currentVersion, - CancellationToken ct) - { - return SelectProvider().GetLatestAsync(channel, platform, currentVersion, ct); - } - - public Task GetByVersionAsync( - string version, - string channel, - string platform, - CancellationToken ct) - { - return SelectProvider().GetByVersionAsync(version, channel, platform, ct); - } - - public Task> GetIncrementalChainAsync( - string channel, - string platform, - Version fromVersion, - Version toVersion, - CancellationToken ct) - { - return SelectProvider().GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct); - } - - private IUpdateManifestProvider SelectProvider() - { - var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsFacade.Update.Get().UpdateDownloadSource); - return string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) || - string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase) - ? _github - : _plondsWithFallback; - } -} diff --git a/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs b/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs index ba863db..7229ac6 100644 --- a/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs +++ b/LanMountainDesktop/Services/Update/UpdateManifestMapper.cs @@ -7,71 +7,8 @@ internal static class UpdateManifestMapper { public static UpdateManifest FromGitHubRelease( GitHubReleaseInfo release, - PlondsUpdatePayload? plondsPayload, string channel, - string platform) - { - if (plondsPayload is not null) - { - return FromPlondsPayload(plondsPayload, release, channel, platform); - } - - return FromFullInstaller(release, channel, platform); - } - - public static UpdateManifest FromPlondsPayload( - PlondsUpdatePayload payload, - GitHubReleaseInfo release, - string channel, - string platform) - { - var files = new List(); - - if (payload.UpdateArchiveUrl is not null) - { - files.Add(new UpdateFileEntry( - Path: "update.zip", - Action: "add", - Sha256: payload.UpdateArchiveSha256 ?? string.Empty, - Size: payload.UpdateArchiveSizeBytes ?? 0, - Mode: "compressed-object", - ObjectKey: null, - ObjectUrl: payload.UpdateArchiveUrl, - ArchiveSha256: null, - Metadata: null)); - } - - var mirrors = release.Assets - .Where(IsInstallerAsset) - .Select(a => new UpdateMirrorAsset( - Platform: platform, - Url: a.BrowserDownloadUrl, - Name: a.Name, - Sha256: a.Sha256, - Size: a.SizeBytes)) - .ToArray(); - - var metadata = new Dictionary - { - ["source"] = "github-plonds", - ["releaseTag"] = release.TagName - }; - - return new UpdateManifest( - DistributionId: payload.DistributionId, - FromVersion: string.Empty, - ToVersion: NormalizeTagVersion(release.TagName), - Platform: platform, - Channel: channel, - PublishedAt: release.PublishedAt, - Kind: UpdatePayloadKind.DeltaPlonds, - FileMapUrl: payload.FileMapJsonUrl, - FileMapSignatureUrl: payload.FileMapSignatureUrl, - FileMapSha256: null, - Files: files, - InstallerMirrors: mirrors, - Metadata: metadata); - } + string platform) => FromFullInstaller(release, channel, platform); public static UpdateManifest FromFullInstaller( GitHubReleaseInfo release, diff --git a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs index 4d20bad..f880e17 100644 --- a/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs +++ b/LanMountainDesktop/Services/Update/UpdateOrchestrator.cs @@ -25,8 +25,7 @@ internal static class HostUpdateOrchestratorProvider var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); var githubProvider = new GithubReleaseManifestProvider("wwiinnddyy", "LanMountainDesktop"); - var plondsProvider = new PlondsApiManifestProvider("https://api.classisland.tech"); - var manifestProvider = new SettingsUpdateManifestProvider(settingsFacade, plondsProvider, githubProvider); + var manifestProvider = githubProvider; var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(30) }; var downloadEngine = new UpdateDownloadEngine(manifestProvider, new ResumableDownloadService(httpClient)); var installGateway = new UpdateInstallGateway(); @@ -128,7 +127,7 @@ public sealed class UpdateOrchestrator : IDisposable UpdateManifest? manifest; try { - var platform = LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform(); + var platform = ResolveCurrentPlatform(); manifest = settings.ForceUpdateReinstall ? await _manifestProvider.GetByVersionAsync( currentVersionText, @@ -711,6 +710,24 @@ public sealed class UpdateOrchestrator : IDisposable return true; } + private static string ResolveCurrentPlatform() + { + var os = OperatingSystem.IsWindows() + ? "windows" + : OperatingSystem.IsLinux() + ? "linux" + : OperatingSystem.IsMacOS() + ? "macos" + : "unknown"; + var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch + { + System.Runtime.InteropServices.Architecture.Arm64 => "arm64", + System.Runtime.InteropServices.Architecture.X86 => "x86", + _ => "x64" + }; + return $"{os}-{arch}"; + } + private void OnPhaseChanged(UpdatePhase phase) { PhaseChanged?.Invoke(phase); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs index 37c680f..823d2ac 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using Plonds.Shared.Models; @@ -53,6 +54,7 @@ public sealed class PlondsPublisher ZipFile.ExtractToDirectory(filesZipPath, filesExtractRoot, overwriteFiles: true); var manifestKey = $"{versionPrefix}/PLONDS.json"; + var latestManifestKey = $"{prefix}/PLONDS.json"; var changedZipKey = $"{versionPrefix}/changed.zip"; var changedFolderKey = $"{versionPrefix}/{changedFolderName}"; var filesZipKey = $"{versionPrefix}/Files.zip"; @@ -66,8 +68,15 @@ public sealed class PlondsPublisher await s3.UploadFileAsync(new PlondsS3ObjectUpload(changedZipPath, changedZipKey, "application/zip"), cancellationToken).ConfigureAwait(false); await s3.UploadFileAsync(new PlondsS3ObjectUpload(filesZipPath, filesZipKey, "application/zip"), cancellationToken).ConfigureAwait(false); + var updatedChecksums = new Dictionary(manifest.Checksums, StringComparer.OrdinalIgnoreCase) + { + ["changed.zip"] = NormalizeChecksum(manifest.Checksums, "changed.zip", changedZipPath), + ["Files.zip"] = $"md5:{ComputeMd5Hex(filesZipPath)}" + }; + var updatedManifest = manifest with { + Checksums = updatedChecksums, Downloads = new PlondsDownloadInfo( ReleaseTag: releaseTag, GitHub: new PlondsGitHubDownloadInfo( @@ -92,8 +101,10 @@ public sealed class PlondsPublisher File.WriteAllText(manifestPath, JsonSerializer.Serialize(updatedManifest, JsonOptions), new UTF8Encoding(false)); await s3.UploadFileAsync(new PlondsS3ObjectUpload(manifestPath, manifestKey, "application/json"), cancellationToken).ConfigureAwait(false); + await s3.UploadFileAsync(new PlondsS3ObjectUpload(manifestPath, latestManifestKey, "application/json"), cancellationToken).ConfigureAwait(false); await s3.EnsureObjectExistsAsync(manifestKey, cancellationToken).ConfigureAwait(false); + await s3.EnsureObjectExistsAsync(latestManifestKey, cancellationToken).ConfigureAwait(false); await s3.EnsureObjectExistsAsync(changedZipKey, cancellationToken).ConfigureAwait(false); await s3.EnsureObjectExistsAsync(filesZipKey, cancellationToken).ConfigureAwait(false); @@ -140,6 +151,22 @@ public sealed class PlondsPublisher ?? throw new InvalidOperationException("PLONDS manifest is empty or invalid."); } + private static string NormalizeChecksum( + IReadOnlyDictionary checksums, + string key, + string filePath) + { + return checksums.TryGetValue(key, out var checksum) && !string.IsNullOrWhiteSpace(checksum) + ? checksum + : $"md5:{ComputeMd5Hex(filePath)}"; + } + + private static string ComputeMd5Hex(string filePath) + { + using var stream = File.OpenRead(filePath); + return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant(); + } + private static string NormalizePrefix(string value) { var normalized = Require(value, nameof(value)).Replace('\\', '/').Trim('/'); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs index a6d58cb..cdb6b84 100644 --- a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsManifest.cs @@ -12,4 +12,5 @@ public sealed record PlondsManifest( IReadOnlyDictionary FilesMap, IReadOnlyDictionary ChangedFilesMap, IReadOnlyDictionary Checksums, - PlondsDownloadInfo? Downloads = null); + PlondsDownloadInfo? Downloads = null, + IReadOnlyList? Sources = null); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSourceDescriptor.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSourceDescriptor.cs new file mode 100644 index 0000000..ec0842a --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSourceDescriptor.cs @@ -0,0 +1,7 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsSourceDescriptor( + string Id, + string Kind, + string ManifestUrl, + int Priority = 0);