feat.PLONDS客户端

This commit is contained in:
lincube
2026-06-01 19:48:51 +08:00
parent 0c8830133a
commit 03e4442e74
39 changed files with 2582 additions and 991 deletions

View File

@@ -113,6 +113,7 @@ Publisher 上传到 S3 的版本目录:
- `Files.zip` 是上传到 S3 时的完整包标准名。
- `<version>-Files/` 是 S3 上解压后的完整包目录。
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。

View File

@@ -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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["https://github.test/PLONDS.json"] = ManifestJson("1.7.0")
},
throwingUrls: new HashSet<string>(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<string, string>
{
["changed.zip"] = Md5Checksum(changedZip),
["Files.zip"] = Md5Checksum(filesZip)
});
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
{
["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<string, string>
{
["changed.zip"] = "md5:00000000000000000000000000000000",
["Files.zip"] = Md5Checksum(filesZip)
});
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
{
["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<string, string>
{
["Files.zip"] = Md5Checksum(filesZip)
});
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
{
["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<string, string>
{
["changed.zip"] = "md5:00000000000000000000000000000000",
["Files.zip"] = "md5:11111111111111111111111111111111"
});
using var httpClient = new HttpClient(new AssetHandler(new Dictionary<string, byte[]>
{
["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<PlondsSourceDescriptor>? sources = null,
PlondsClientDownloads? downloads = null,
IReadOnlyDictionary<string, string>? 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<string, PlondsClientFileEntry>(),
ChangedFilesMap: new Dictionary<string, PlondsClientChangedFileEntry>(),
Checksums: checksums ?? new Dictionary<string, string>(),
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<PlondsPreparedPackage> PrepareDeltaAsync(
PlondsClientManifest manifest,
PlondsSourceDescriptor source,
CancellationToken cancellationToken)
{
DeltaCalls++;
if (deltaFails)
{
throw new InvalidOperationException("delta failed");
}
return Task.FromResult(CreatePackage(manifest, PlondsPackageMode.Delta));
}
public Task<PlondsPreparedPackage> 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<PlondsPreparedPackage> 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<PlondsPreparedPackage> 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<string, string> manifests,
IReadOnlySet<string>? throwingUrls = null) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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<string, byte[]> assets) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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)
});
}
}
}

View File

@@ -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 manifest = await provider.GetLatestAsync(
UpdateSettingsValues.ChannelStable,
"windows-x64",
var plonds = new FakePlondsService
{
LatestResult = PlondsLatestResult.Available(
new Version(1, 0, 0),
CancellationToken.None);
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);
Assert.Equal("github", manifest?.DistributionId);
Assert.Equal(0, plonds.GetLatestCalls);
Assert.Equal(1, github.GetLatestCalls);
var report = await service.CheckAsync(CancellationToken.None);
update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds };
manifest = await provider.GetLatestAsync(
UpdateSettingsValues.ChannelStable,
"windows-x64",
new Version(1, 0, 0),
CancellationToken.None);
Assert.True(report.IsUpdateAvailable);
Assert.Equal("9.9.9", report.LatestVersion);
Assert.Equal(1, plonds.FindLatestCalls);
Assert.False(orchestratorCreated);
}
Assert.Equal("plonds", manifest?.DistributionId);
Assert.Equal(1, plonds.GetLatestCalls);
[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());
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<string, PlondsClientFileEntry>(),
ChangedFilesMap: new Dictionary<string, PlondsClientChangedFileEntry>(),
Checksums: new Dictionary<string, string>(),
Downloads: null,
Sources: []);
}
private sealed class FakeUpdateSettingsService : IUpdateSettingsService
{
public SettingsUpdateState State { get; set; } = DefaultUpdateState();
@@ -263,9 +330,6 @@ public sealed class UpdateSettingsInterfaceTests
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default)
=> CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
public Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default)
=> Task.FromResult<PlondsUpdatePayload?>(null);
public Task<LanMountainDesktop.Services.UpdateDownloadResult> 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<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken)
{
FindLatestCalls++;
return Task.FromResult(LatestResult);
}
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken)
{
PrepareLatestCalls++;
return Task.FromResult(PrepareResult);
}
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken)
{
PrepareLatestCalls++;
return Task.FromResult(PrepareResult);
}
}
private sealed class FakeSettingsService : ISettingsService
{
public event EventHandler<SettingsChangedEvent>? Changed;
public AppSettingsSnapshot Snapshot { get; init; } = new();
public T LoadSnapshot<T>(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<T>(
SettingsScope scope,
T snapshot,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
if (snapshot is AppSettingsSnapshot appSettings)
{
CopyUpdateSettings(appSettings, Snapshot);
}
Changed?.Invoke(this, new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
}
public T LoadSection<T>(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new()
=> new();
public void SaveSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
T section,
string? placementId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
}
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
{
}
public T? GetValue<T>(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null)
=> default;
public void SetValue<T>(
SettingsScope scope,
string key,
T value,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? 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<string, string>());
}
private sealed class EmptyHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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();

View File

@@ -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<GitHubReleaseAsset> assets, string assetName)
{
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
}
private static string GetPlatformAssetSuffix()
{
var os = OperatingSystem.IsWindows()

View File

@@ -0,0 +1,14 @@
namespace LanMountainDesktop.Services.Plonds;
internal interface IPlondsPackageDownloader
{
Task<PlondsPreparedPackage> PrepareDeltaAsync(
PlondsClientManifest manifest,
PlondsSourceDescriptor source,
CancellationToken cancellationToken);
Task<PlondsPreparedPackage> PrepareFullAsync(
PlondsClientManifest manifest,
PlondsSourceDescriptor source,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.Services.Plonds;
internal interface IPlondsService
{
Task<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken);
Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken);
Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken);
}

View File

@@ -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);

View File

@@ -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<string, PlondsClientFileEntry> FilesMap,
IReadOnlyDictionary<string, PlondsClientChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums,
PlondsClientDownloads? Downloads,
IReadOnlyList<PlondsSourceDescriptor>? 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");

View File

@@ -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<PlondsSourceDescriptor> 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();
}
}

View File

@@ -0,0 +1,44 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed class PlondsDownloadPlanner(IPlondsPackageDownloader downloader)
{
public async Task<PlondsPrepareResult> 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}");
}
}
}
}

View File

@@ -0,0 +1,67 @@
namespace LanMountainDesktop.Services.Plonds;
internal static class PlondsDownloadUrlResolver
{
public static IReadOnlyList<Uri> Resolve(
PlondsClientManifest manifest,
PlondsSourceDescriptor source,
PlondsPackageMode mode)
{
var urls = new List<string?>();
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<Uri>()
.Where(uri => uri.Scheme is "http" or "https")
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static void AddS3(List<string?> urls, PlondsClientManifest manifest, PlondsPackageMode mode)
{
urls.Add(mode is PlondsPackageMode.Delta
? manifest.Downloads?.S3?.ChangedZipUrl
: manifest.Downloads?.S3?.FilesZipUrl);
}
private static void AddGitHub(List<string?> 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;
}
}

View File

@@ -0,0 +1,118 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed class PlondsHttpPackageDownloader(
HttpClient httpClient,
PlondsPackageStore packageStore,
PlondsVerifier verifier) : IPlondsPackageDownloader
{
public Task<PlondsPreparedPackage> 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<PlondsPreparedPackage> PrepareFullAsync(
PlondsClientManifest manifest,
PlondsSourceDescriptor source,
CancellationToken cancellationToken)
{
return PrepareAsync(manifest, source, PlondsPackageMode.Full, cancellationToken);
}
private async Task<PlondsPreparedPackage> 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<string> 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();
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsInstallResult(
bool Success,
string? ErrorMessage,
string? ErrorCode = null);

View File

@@ -0,0 +1,28 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsLatestResult(
bool Success,
bool IsUpdateAvailable,
Version CurrentVersion,
Version? LatestVersion,
IReadOnlyList<PlondsManifestCandidate> Candidates,
string? ErrorMessage)
{
public static PlondsLatestResult Available(
Version currentVersion,
Version latestVersion,
IReadOnlyList<PlondsManifestCandidate> 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);
}
}

View File

@@ -0,0 +1,5 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsManifestCandidate(
PlondsSourceDescriptor Source,
PlondsClientManifest Manifest);

View File

@@ -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<PlondsClientManifest?> 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<PlondsClientManifest>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
}

View File

@@ -0,0 +1,53 @@
namespace LanMountainDesktop.Services.Plonds;
internal static class PlondsManifestSelector
{
public static PlondsManifestCandidate? SelectHighestVersion(IEnumerable<PlondsManifestCandidate> candidates)
{
return SelectHighestVersionCandidates(candidates).FirstOrDefault();
}
public static IReadOnlyList<PlondsManifestCandidate> SelectHighestVersionCandidates(IEnumerable<PlondsManifestCandidate> 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);
}
}

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.Services.Plonds;
internal enum PlondsPackageMode
{
Delta,
Full
}

View File

@@ -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<PlondsPackageStaging> 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);
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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<PlondsInstallResult> InstallAsync(
PlondsPreparedPackage package,
string launcherRoot,
IProgress<InstallProgressReport>? 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<InstallProgressReport>? 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<InstallProgressReport>? 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<string, PlondsClientFileEntry>();
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<PlondsClientManifest> 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<PlondsClientManifest>(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<string, PlondsClientFileEntry> fileEntries,
string targetDeployment,
IProgress<InstallProgressReport>? 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
{
}
}
}

View File

@@ -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<PlondsLatestResult> 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<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken)
{
return FindAndPrepareLatestAsync(new Version(0, 0, 0), cancellationToken);
}
public async Task<PlondsPrepareResult> 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<string>();
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<IReadOnlyList<PlondsManifestCandidate>> DiscoverHighestVersionCandidatesAsync(CancellationToken cancellationToken)
{
var candidates = new List<PlondsManifestCandidate>();
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);
}
}

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed record PlondsSourceDescriptor(
string Id,
string Kind,
string ManifestUrl,
int Priority = 0);

View File

@@ -0,0 +1,57 @@
namespace LanMountainDesktop.Services.Plonds;
internal sealed class PlondsSourceRegistry
{
private readonly List<PlondsSourceDescriptor> _sources = [];
public PlondsSourceRegistry(IEnumerable<PlondsSourceDescriptor> initialSources)
{
AddRange(initialSources);
}
public IReadOnlyList<PlondsSourceDescriptor> Sources => _sources;
public void AddRange(IEnumerable<PlondsSourceDescriptor>? 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);
}
}

View File

@@ -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<IReadOnlyList<PlondsSourceDescriptor>> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_sourceFilePath))
{
return [];
}
await using var stream = File.OpenRead(_sourceFilePath);
var document = await JsonSerializer.DeserializeAsync<PlondsSourceStoreDocument>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
return document?.Sources ?? [];
}
public async Task SaveAsync(IEnumerable<PlondsSourceDescriptor> 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<PlondsSourceDescriptor> Sources);
}

View File

@@ -0,0 +1,104 @@
using System.Security.Cryptography;
namespace LanMountainDesktop.Services.Plonds;
internal sealed class PlondsVerifier
{
public async Task VerifyFileAsync(
string filePath,
IReadOnlyDictionary<string, string>? checksums,
IEnumerable<string> 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<string, string>? checksums,
IEnumerable<string> 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<string> 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();
}
}

View File

@@ -1,80 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class PlondsReleaseUpdateService : IDisposable
{
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
public Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> 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);
}
}

View File

@@ -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<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> 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<UpdateCheckResult> 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<LatestPointerDto>(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<DistributionDto>(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<T?> GetJsonAsync<T>(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<T>(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);
}

View File

@@ -375,7 +375,6 @@ public interface IUpdateSettingsService
bool TryApplyOnExit();
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,

View File

@@ -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<UpdateOrchestrator> _orchestrator;
private PlondsLatestResult? _pendingPlondsLatest;
private PlondsPreparedPackage? _pendingPlondsPackage;
private UpdatePhase _plondsPhase = UpdatePhase.Idle;
private bool _orchestratorEventsSubscribed;
public UpdateSettingsService(ISettingsService settingsService, Func<UpdateOrchestrator>? orchestratorFactory = null)
public UpdateSettingsService(
ISettingsService settingsService,
Func<UpdateOrchestrator>? orchestratorFactory = null,
IPlondsService? plondsService = null)
{
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_plondsService = plondsService ?? PlondsClientServiceFactory.CreateDefault();
_orchestrator = new Lazy<UpdateOrchestrator>(
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<UpdatePhase>? PhaseChanged
{
add => _orchestrator.Value.PhaseChanged += value;
add
{
_phaseChanged += value;
}
remove
{
if (_orchestrator.IsValueCreated)
{
_orchestrator.Value.PhaseChanged -= value;
}
_phaseChanged -= value;
}
}
public event Action<UpdateProgressReport>? ProgressChanged
{
add => _orchestrator.Value.ProgressChanged += value;
add
{
_progressChanged += value;
}
remove
{
if (_orchestrator.IsValueCreated)
{
_orchestrator.Value.ProgressChanged -= value;
}
_progressChanged -= value;
}
}
private event Action<UpdatePhase>? _phaseChanged;
private event Action<UpdateProgressReport>? _progressChanged;
public UpdateSettingsState Get()
{
var snapshot = _settingsService.Load();
@@ -900,47 +914,75 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public Task<UpdateCheckReport> CheckAsync(CancellationToken cancellationToken = default)
{
return _orchestrator.Value.CheckAsync(cancellationToken);
return IsPlondsSelected()
? CheckPlondsAsync(cancellationToken)
: GetOrchestrator().CheckAsync(cancellationToken);
}
public Task<LanMountainDesktop.Services.Update.DownloadResult> DownloadAsync(CancellationToken cancellationToken = default)
{
return _orchestrator.Value.DownloadAsync(cancellationToken);
return IsPlondsSelected()
? DownloadPlondsAsync(cancellationToken)
: GetOrchestrator().DownloadAsync(cancellationToken);
}
public Task<InstallResult> 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<LanMountainDesktop.Services.Update.DownloadResult> 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<UpdateCheckResult> CheckForUpdatesAsync(
@@ -959,26 +1001,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public async Task<PlondsUpdatePayload?> 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<UpdateDownloadResult> 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<UpdateCheckResult> 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);
if (staticResult.Success)
{
return staticResult;
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);
}
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)
private async Task<UpdateCheckReport> CheckPlondsAsync(CancellationToken cancellationToken)
{
return plondsResult;
if (!_plondsPhase.CanCheck())
{
return new UpdateCheckReport(false, null, null, null, null, null, null, null, null, $"Cannot check in phase {_plondsPhase}.");
}
AppLogger.Warn(
"UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
TransitionPlonds(UpdatePhase.Checking);
var currentVersionText = LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider.ResolveForCurrentProcess().Version;
if (!TryParseVersion(currentVersionText, out var currentVersion))
{
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}");
TransitionPlonds(UpdatePhase.Failed);
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, $"Invalid current version text: {currentVersionText}");
}
return githubFallbackResult;
var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
_pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null;
_pendingPlondsPackage = null;
TransitionPlonds(UpdatePhase.Checked);
SaveLastChecked();
if (!latest.Success)
{
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
}
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<LanMountainDesktop.Services.Update.DownloadResult> 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<LanMountainDesktop.Services.Update.DownloadResult> 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<InstallResult> 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<InstallProgressReport>(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!);
}
}

View File

@@ -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<UpdateManifest?> 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<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
@@ -67,65 +66,11 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
}
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<GitHubReleaseAsset> 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}";
}
}

View File

@@ -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<UpdateManifest?> 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<UpdateManifest?> GetByVersionAsync(
string version,
string channel,
string platform,
CancellationToken ct)
{
var distributionId = $"{channel}-{platform}-{version}";
return await FetchDistributionManifestAsync(distributionId, version, channel, platform, ct);
}
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
string channel,
string platform,
Version fromVersion,
Version toVersion,
CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
private async Task<PlondsChannelPointerDto?> 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<PlondsChannelPointerDto>(json, PlondsJsonOptions);
}
private async Task<UpdateManifest?> 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<PlondsDistributionDto>(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<UpdateFileEntry>();
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<string, string> ?? new Dictionary<string, string>());
}
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<PlondsComponentDto>? Components,
List<PlondsMirrorDto>? InstallerMirrors,
List<PlondsSignatureDto>? Signatures,
Dictionary<string, string>? Metadata);
private sealed record PlondsComponentDto(
string? Id,
string? Root,
string? Mode,
List<PlondsFileDto>? 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();
}
}

View File

@@ -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<UpdateManifest?> GetLatestAsync(
string channel,
string platform,
Version currentVersion,
CancellationToken ct)
{
return SelectProvider().GetLatestAsync(channel, platform, currentVersion, ct);
}
public Task<UpdateManifest?> GetByVersionAsync(
string version,
string channel,
string platform,
CancellationToken ct)
{
return SelectProvider().GetByVersionAsync(version, channel, platform, ct);
}
public Task<IReadOnlyList<UpdateManifest>> 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;
}
}

View File

@@ -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<UpdateFileEntry>();
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<string, string>
{
["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,

View File

@@ -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);

View File

@@ -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<string, string>(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<string, string> 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('/');

View File

@@ -12,4 +12,5 @@ public sealed record PlondsManifest(
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums,
PlondsDownloadInfo? Downloads = null);
PlondsDownloadInfo? Downloads = null,
IReadOnlyList<PlondsSourceDescriptor>? Sources = null);

View File

@@ -0,0 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record PlondsSourceDescriptor(
string Id,
string Kind,
string ManifestUrl,
int Priority = 0);