mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
feat.PLONDS客户端
This commit is contained in:
@@ -113,6 +113,7 @@ Publisher 上传到 S3 的版本目录:
|
|||||||
|
|
||||||
- `Files.zip` 是上传到 S3 时的完整包标准名。
|
- `Files.zip` 是上传到 S3 时的完整包标准名。
|
||||||
- `<version>-Files/` 是 S3 上解压后的完整包目录。
|
- `<version>-Files/` 是 S3 上解压后的完整包目录。
|
||||||
|
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
|
||||||
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。
|
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。
|
||||||
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
||||||
|
|
||||||
|
|||||||
648
LanMountainDesktop.Tests/PlondsClientServiceTests.cs
Normal file
648
LanMountainDesktop.Tests/PlondsClientServiceTests.cs
Normal 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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Plonds;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.Services.Update;
|
using LanMountainDesktop.Services.Update;
|
||||||
using LanMountainDesktop.Shared.Contracts.Update;
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
@@ -103,35 +104,74 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 plonds = new FakePlondsService
|
||||||
var github = new FakeManifestProvider("github");
|
{
|
||||||
var provider = new SettingsUpdateManifestProvider(new FakeSettingsFacade(update), plonds, github);
|
LatestResult = PlondsLatestResult.Available(
|
||||||
|
new Version(1, 0, 0),
|
||||||
|
new Version(9, 9, 9),
|
||||||
|
[new PlondsManifestCandidate(
|
||||||
|
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||||
|
CreatePlondsManifest("9.9.9"))])
|
||||||
|
};
|
||||||
|
var orchestratorCreated = false;
|
||||||
|
var service = new UpdateSettingsService(
|
||||||
|
settings,
|
||||||
|
orchestratorFactory: () =>
|
||||||
|
{
|
||||||
|
orchestratorCreated = true;
|
||||||
|
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS.");
|
||||||
|
},
|
||||||
|
plondsService: plonds);
|
||||||
|
|
||||||
var manifest = await provider.GetLatestAsync(
|
var report = await service.CheckAsync(CancellationToken.None);
|
||||||
UpdateSettingsValues.ChannelStable,
|
|
||||||
"windows-x64",
|
|
||||||
new Version(1, 0, 0),
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal("github", manifest?.DistributionId);
|
Assert.True(report.IsUpdateAvailable);
|
||||||
Assert.Equal(0, plonds.GetLatestCalls);
|
Assert.Equal("9.9.9", report.LatestVersion);
|
||||||
Assert.Equal(1, github.GetLatestCalls);
|
Assert.Equal(1, plonds.FindLatestCalls);
|
||||||
|
Assert.False(orchestratorCreated);
|
||||||
|
}
|
||||||
|
|
||||||
update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds };
|
[Fact]
|
||||||
manifest = await provider.GetLatestAsync(
|
public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator()
|
||||||
UpdateSettingsValues.ChannelStable,
|
{
|
||||||
"windows-x64",
|
var settings = new FakeSettingsService
|
||||||
new Version(1, 0, 0),
|
{
|
||||||
CancellationToken.None);
|
Snapshot =
|
||||||
|
{
|
||||||
|
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var orchestrator = CreateTestOrchestrator(DefaultUpdateState() with
|
||||||
|
{
|
||||||
|
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||||
|
});
|
||||||
|
var orchestratorCreated = false;
|
||||||
|
var service = new UpdateSettingsService(
|
||||||
|
settings,
|
||||||
|
orchestratorFactory: () =>
|
||||||
|
{
|
||||||
|
orchestratorCreated = true;
|
||||||
|
return orchestrator;
|
||||||
|
},
|
||||||
|
plondsService: new FakePlondsService());
|
||||||
|
|
||||||
Assert.Equal("plonds", manifest?.DistributionId);
|
var _ = service.CurrentPhase;
|
||||||
Assert.Equal(1, plonds.GetLatestCalls);
|
|
||||||
|
Assert.False(orchestratorCreated);
|
||||||
|
|
||||||
|
var report = await service.CheckAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(orchestratorCreated);
|
||||||
|
Assert.True(report.IsUpdateAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -177,6 +217,33 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
LastUpdateCheckUtcMs: null,
|
LastUpdateCheckUtcMs: null,
|
||||||
PendingUpdateSha256: 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
|
private sealed class FakeUpdateSettingsService : IUpdateSettingsService
|
||||||
{
|
{
|
||||||
public SettingsUpdateState State { get; set; } = DefaultUpdateState();
|
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)
|
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default)
|
||||||
=> CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
=> 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(
|
public Task<LanMountainDesktop.Services.UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
@@ -285,6 +349,115 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
=> Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false));
|
=> 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
|
private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider
|
||||||
{
|
{
|
||||||
public string ProviderName { get; } = providerName;
|
public string ProviderName { get; } = providerName;
|
||||||
@@ -318,6 +491,14 @@ public sealed class UpdateSettingsInterfaceTests
|
|||||||
new Dictionary<string, string>());
|
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
|
private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService
|
||||||
{
|
{
|
||||||
public ISettingsService Settings => throw new NotSupportedException();
|
public ISettingsService Settings => throw new NotSupportedException();
|
||||||
|
|||||||
@@ -34,20 +34,7 @@ public sealed record UpdateCheckResult(
|
|||||||
GitHubReleaseInfo? Release,
|
GitHubReleaseInfo? Release,
|
||||||
GitHubReleaseAsset? PreferredAsset,
|
GitHubReleaseAsset? PreferredAsset,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage,
|
||||||
bool ForceMode = false,
|
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);
|
|
||||||
|
|
||||||
public sealed record UpdateDownloadResult(
|
public sealed record UpdateDownloadResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
@@ -162,10 +149,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
var preferredAsset = isUpdateAvailable
|
var preferredAsset = isUpdateAvailable
|
||||||
? SelectPreferredInstallerAsset(release.Assets)
|
? SelectPreferredInstallerAsset(release.Assets)
|
||||||
: null;
|
: null;
|
||||||
var plondsPayload = isUpdateAvailable
|
|
||||||
? TryResolvePlondsPayload(release)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
IsUpdateAvailable: isUpdateAvailable,
|
IsUpdateAvailable: isUpdateAvailable,
|
||||||
@@ -173,8 +156,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
LatestVersionText: latestVersionText,
|
LatestVersionText: latestVersionText,
|
||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: preferredAsset,
|
PreferredAsset: preferredAsset,
|
||||||
ErrorMessage: null,
|
ErrorMessage: null);
|
||||||
PlondsPayload: plondsPayload);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -239,8 +221,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
: release.TagName;
|
: release.TagName;
|
||||||
|
|
||||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||||
var plondsPayload = TryResolvePlondsPayload(release);
|
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
IsUpdateAvailable: true,
|
IsUpdateAvailable: true,
|
||||||
@@ -249,8 +229,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: preferredAsset,
|
PreferredAsset: preferredAsset,
|
||||||
ErrorMessage: null,
|
ErrorMessage: null,
|
||||||
ForceMode: true,
|
ForceMode: true);
|
||||||
PlondsPayload: plondsPayload);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -703,46 +682,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
return null;
|
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()
|
private static string GetPlatformAssetSuffix()
|
||||||
{
|
{
|
||||||
var os = OperatingSystem.IsWindows()
|
var os = OperatingSystem.IsWindows()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
10
LanMountainDesktop/Services/Plonds/IPlondsService.cs
Normal file
10
LanMountainDesktop/Services/Plonds/IPlondsService.cs
Normal 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);
|
||||||
|
}
|
||||||
25
LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs
Normal file
25
LanMountainDesktop/Services/Plonds/PlondsClientDownloads.cs
Normal 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);
|
||||||
28
LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs
Normal file
28
LanMountainDesktop/Services/Plonds/PlondsClientManifest.cs
Normal 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");
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs
Normal file
44
LanMountainDesktop/Services/Plonds/PlondsDownloadPlanner.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.Services.Plonds;
|
||||||
|
|
||||||
|
internal sealed record PlondsInstallResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
string? ErrorCode = null);
|
||||||
28
LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs
Normal file
28
LanMountainDesktop/Services/Plonds/PlondsLatestResult.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace LanMountainDesktop.Services.Plonds;
|
||||||
|
|
||||||
|
internal sealed record PlondsManifestCandidate(
|
||||||
|
PlondsSourceDescriptor Source,
|
||||||
|
PlondsClientManifest Manifest);
|
||||||
27
LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs
Normal file
27
LanMountainDesktop/Services/Plonds/PlondsManifestClient.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
53
LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs
Normal file
53
LanMountainDesktop/Services/Plonds/PlondsManifestSelector.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs
Normal file
7
LanMountainDesktop/Services/Plonds/PlondsPackageMode.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.Services.Plonds;
|
||||||
|
|
||||||
|
internal enum PlondsPackageMode
|
||||||
|
{
|
||||||
|
Delta,
|
||||||
|
Full
|
||||||
|
}
|
||||||
155
LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs
Normal file
155
LanMountainDesktop/Services/Plonds/PlondsPackageStore.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs
Normal file
12
LanMountainDesktop/Services/Plonds/PlondsPrepareResult.cs
Normal 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);
|
||||||
|
}
|
||||||
10
LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs
Normal file
10
LanMountainDesktop/Services/Plonds/PlondsPreparedPackage.cs
Normal 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);
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
LanMountainDesktop/Services/Plonds/PlondsService.cs
Normal file
107
LanMountainDesktop/Services/Plonds/PlondsService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.Services.Plonds;
|
||||||
|
|
||||||
|
internal sealed record PlondsSourceDescriptor(
|
||||||
|
string Id,
|
||||||
|
string Kind,
|
||||||
|
string ManifestUrl,
|
||||||
|
int Priority = 0);
|
||||||
57
LanMountainDesktop/Services/Plonds/PlondsSourceRegistry.cs
Normal file
57
LanMountainDesktop/Services/Plonds/PlondsSourceRegistry.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
LanMountainDesktop/Services/Plonds/PlondsSourceStore.cs
Normal file
57
LanMountainDesktop/Services/Plonds/PlondsSourceStore.cs
Normal 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);
|
||||||
|
}
|
||||||
104
LanMountainDesktop/Services/Plonds/PlondsVerifier.cs
Normal file
104
LanMountainDesktop/Services/Plonds/PlondsVerifier.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -375,7 +375,6 @@ public interface IUpdateSettingsService
|
|||||||
bool TryApplyOnExit();
|
bool TryApplyOnExit();
|
||||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||||
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(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(
|
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using Avalonia.Media.Imaging;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Plonds;
|
||||||
using LanMountainDesktop.Services.Update;
|
using LanMountainDesktop.Services.Update;
|
||||||
using LanMountainDesktop.Settings.Core;
|
using LanMountainDesktop.Settings.Core;
|
||||||
using LanMountainDesktop.Services.PluginMarket;
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
@@ -788,44 +789,57 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
{
|
{
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||||
private readonly PlondsStaticUpdateService _plondsStaticUpdateService = new();
|
private readonly IPlondsService _plondsService;
|
||||||
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
|
private readonly PlondsPreparedPackageInstaller _plondsInstaller = new();
|
||||||
private readonly Lazy<UpdateOrchestrator> _orchestrator;
|
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));
|
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||||
|
_plondsService = plondsService ?? PlondsClientServiceFactory.CreateDefault();
|
||||||
_orchestrator = new Lazy<UpdateOrchestrator>(
|
_orchestrator = new Lazy<UpdateOrchestrator>(
|
||||||
orchestratorFactory ?? HostUpdateOrchestratorProvider.GetOrCreate,
|
orchestratorFactory ?? HostUpdateOrchestratorProvider.GetOrCreate,
|
||||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
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
|
public event Action<UpdatePhase>? PhaseChanged
|
||||||
{
|
{
|
||||||
add => _orchestrator.Value.PhaseChanged += value;
|
add
|
||||||
|
{
|
||||||
|
_phaseChanged += value;
|
||||||
|
}
|
||||||
remove
|
remove
|
||||||
{
|
{
|
||||||
if (_orchestrator.IsValueCreated)
|
_phaseChanged -= value;
|
||||||
{
|
|
||||||
_orchestrator.Value.PhaseChanged -= value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public event Action<UpdateProgressReport>? ProgressChanged
|
public event Action<UpdateProgressReport>? ProgressChanged
|
||||||
{
|
{
|
||||||
add => _orchestrator.Value.ProgressChanged += value;
|
add
|
||||||
|
{
|
||||||
|
_progressChanged += value;
|
||||||
|
}
|
||||||
remove
|
remove
|
||||||
{
|
{
|
||||||
if (_orchestrator.IsValueCreated)
|
_progressChanged -= value;
|
||||||
{
|
|
||||||
_orchestrator.Value.ProgressChanged -= value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private event Action<UpdatePhase>? _phaseChanged;
|
||||||
|
private event Action<UpdateProgressReport>? _progressChanged;
|
||||||
|
|
||||||
public UpdateSettingsState Get()
|
public UpdateSettingsState Get()
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var snapshot = _settingsService.Load();
|
||||||
@@ -900,47 +914,75 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
|
|
||||||
public Task<UpdateCheckReport> CheckAsync(CancellationToken cancellationToken = default)
|
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)
|
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)
|
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)
|
public Task RollbackAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _orchestrator.Value.RollbackAsync(cancellationToken);
|
return GetOrchestrator().RollbackAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task PauseAsync()
|
public Task PauseAsync()
|
||||||
{
|
{
|
||||||
return _orchestrator.Value.PauseAsync();
|
return IsPlondsSelected()
|
||||||
|
? PausePlondsAsync()
|
||||||
|
: GetOrchestrator().PauseAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<LanMountainDesktop.Services.Update.DownloadResult> ResumeAsync(CancellationToken cancellationToken = default)
|
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()
|
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)
|
public Task AutoCheckIfEnabledAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _orchestrator.Value.AutoCheckIfEnabledAsync(cancellationToken);
|
if (IsPlondsSelected())
|
||||||
|
{
|
||||||
|
return AutoCheckPlondsIfEnabledAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetOrchestrator().AutoCheckIfEnabledAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryApplyOnExit()
|
public bool TryApplyOnExit()
|
||||||
{
|
{
|
||||||
return _orchestrator.Value.TryApplyOnExit();
|
if (IsPlondsSelected())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetOrchestrator().TryApplyOnExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||||
@@ -959,26 +1001,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
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(
|
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
@@ -1016,8 +1038,11 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_githubReleaseUpdateService.Dispose();
|
_githubReleaseUpdateService.Dispose();
|
||||||
_plondsStaticUpdateService.Dispose();
|
if (_orchestrator.IsValueCreated && _orchestratorEventsSubscribed)
|
||||||
_plondsReleaseUpdateService.Dispose();
|
{
|
||||||
|
_orchestrator.Value.PhaseChanged -= OnOrchestratorPhaseChanged;
|
||||||
|
_orchestrator.Value.ProgressChanged -= OnOrchestratorProgressChanged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||||
@@ -1026,59 +1051,240 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
bool isForce,
|
bool isForce,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var source = UpdateSettingsValues.NormalizeDownloadSource(Get().UpdateDownloadSource);
|
if (IsGitHubSelected())
|
||||||
if (string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
return isForce
|
return isForce
|
||||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
var staticResult = isForce
|
var result = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||||
? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
return new UpdateCheckResult(
|
||||||
: await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
Success: result.Success,
|
||||||
|
IsUpdateAvailable: isForce || result.IsUpdateAvailable,
|
||||||
|
CurrentVersionText: currentVersion.ToString(),
|
||||||
|
LatestVersionText: result.LatestVersion?.ToString() ?? "-",
|
||||||
|
Release: null,
|
||||||
|
PreferredAsset: null,
|
||||||
|
ErrorMessage: result.ErrorMessage,
|
||||||
|
ForceMode: isForce);
|
||||||
|
}
|
||||||
|
|
||||||
if (staticResult.Success)
|
private async Task<UpdateCheckReport> CheckPlondsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_plondsPhase.CanCheck())
|
||||||
{
|
{
|
||||||
return staticResult;
|
return new UpdateCheckReport(false, null, null, null, null, null, null, null, null, $"Cannot check in phase {_plondsPhase}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.Warn(
|
TransitionPlonds(UpdatePhase.Checking);
|
||||||
"UpdateSettings",
|
var currentVersionText = LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider.ResolveForCurrentProcess().Version;
|
||||||
$"PLONDS static update check failed and will fallback to GitHub release PLONDS. Error: {staticResult.ErrorMessage}");
|
if (!TryParseVersion(currentVersionText, out var currentVersion))
|
||||||
|
|
||||||
var plondsResult = isForce
|
|
||||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
|
||||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
|
||||||
|
|
||||||
if (plondsResult.Success)
|
|
||||||
{
|
{
|
||||||
return plondsResult;
|
TransitionPlonds(UpdatePhase.Failed);
|
||||||
|
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, $"Invalid current version text: {currentVersionText}");
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLogger.Warn(
|
var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||||
"UpdateSettings",
|
_pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null;
|
||||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
_pendingPlondsPackage = null;
|
||||||
|
TransitionPlonds(UpdatePhase.Checked);
|
||||||
|
SaveLastChecked();
|
||||||
|
|
||||||
var githubFallbackResult = isForce
|
if (!latest.Success)
|
||||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
|
||||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
|
||||||
|
|
||||||
if (githubFallbackResult.Success)
|
|
||||||
{
|
{
|
||||||
AppLogger.Info(
|
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
|
||||||
"UpdateSettings",
|
|
||||||
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AppLogger.Warn(
|
|
||||||
"UpdateSettings",
|
|
||||||
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return githubFallbackResult;
|
return new UpdateCheckReport(
|
||||||
|
latest.IsUpdateAvailable,
|
||||||
|
latest.LatestVersion?.ToString(),
|
||||||
|
currentVersionText,
|
||||||
|
latest.IsUpdateAvailable ? UpdatePayloadKind.DeltaPlonds : null,
|
||||||
|
latest.Candidates.FirstOrDefault()?.Source.Id,
|
||||||
|
Get().UpdateChannel,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<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!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using LanMountainDesktop.Shared.Contracts.Update;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Services.Update;
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider, IDisposable
|
||||||
{
|
{
|
||||||
private readonly GitHubReleaseUpdateService _githubService;
|
private readonly GitHubReleaseUpdateService _githubService;
|
||||||
private readonly bool _ownsService;
|
private readonly bool _ownsService;
|
||||||
@@ -37,7 +37,7 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return UpdateManifestMapper.FromGitHubRelease(result.Release, result.PlondsPayload, channel, platform);
|
return UpdateManifestMapper.FromGitHubRelease(result.Release, channel, platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UpdateManifest?> GetByVersionAsync(
|
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||||
@@ -53,8 +53,7 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var plondsPayload = TryResolvePlondsPayload(release);
|
return UpdateManifestMapper.FromGitHubRelease(release, channel, platform);
|
||||||
return UpdateManifestMapper.FromGitHubRelease(release, plondsPayload, channel, platform);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||||
@@ -67,65 +66,11 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
|||||||
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
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}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,71 +7,8 @@ internal static class UpdateManifestMapper
|
|||||||
{
|
{
|
||||||
public static UpdateManifest FromGitHubRelease(
|
public static UpdateManifest FromGitHubRelease(
|
||||||
GitHubReleaseInfo release,
|
GitHubReleaseInfo release,
|
||||||
PlondsUpdatePayload? plondsPayload,
|
|
||||||
string channel,
|
string channel,
|
||||||
string platform)
|
string platform) => FromFullInstaller(release, channel, 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static UpdateManifest FromFullInstaller(
|
public static UpdateManifest FromFullInstaller(
|
||||||
GitHubReleaseInfo release,
|
GitHubReleaseInfo release,
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ internal static class HostUpdateOrchestratorProvider
|
|||||||
|
|
||||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
var githubProvider = new GithubReleaseManifestProvider("wwiinnddyy", "LanMountainDesktop");
|
var githubProvider = new GithubReleaseManifestProvider("wwiinnddyy", "LanMountainDesktop");
|
||||||
var plondsProvider = new PlondsApiManifestProvider("https://api.classisland.tech");
|
var manifestProvider = githubProvider;
|
||||||
var manifestProvider = new SettingsUpdateManifestProvider(settingsFacade, plondsProvider, githubProvider);
|
|
||||||
var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
var downloadEngine = new UpdateDownloadEngine(manifestProvider, new ResumableDownloadService(httpClient));
|
var downloadEngine = new UpdateDownloadEngine(manifestProvider, new ResumableDownloadService(httpClient));
|
||||||
var installGateway = new UpdateInstallGateway();
|
var installGateway = new UpdateInstallGateway();
|
||||||
@@ -128,7 +127,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
UpdateManifest? manifest;
|
UpdateManifest? manifest;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var platform = LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform();
|
var platform = ResolveCurrentPlatform();
|
||||||
manifest = settings.ForceUpdateReinstall
|
manifest = settings.ForceUpdateReinstall
|
||||||
? await _manifestProvider.GetByVersionAsync(
|
? await _manifestProvider.GetByVersionAsync(
|
||||||
currentVersionText,
|
currentVersionText,
|
||||||
@@ -711,6 +710,24 @@ public sealed class UpdateOrchestrator : IDisposable
|
|||||||
return true;
|
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)
|
private void OnPhaseChanged(UpdatePhase phase)
|
||||||
{
|
{
|
||||||
PhaseChanged?.Invoke(phase);
|
PhaseChanged?.Invoke(phase);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Plonds.Shared.Models;
|
using Plonds.Shared.Models;
|
||||||
@@ -53,6 +54,7 @@ public sealed class PlondsPublisher
|
|||||||
ZipFile.ExtractToDirectory(filesZipPath, filesExtractRoot, overwriteFiles: true);
|
ZipFile.ExtractToDirectory(filesZipPath, filesExtractRoot, overwriteFiles: true);
|
||||||
|
|
||||||
var manifestKey = $"{versionPrefix}/PLONDS.json";
|
var manifestKey = $"{versionPrefix}/PLONDS.json";
|
||||||
|
var latestManifestKey = $"{prefix}/PLONDS.json";
|
||||||
var changedZipKey = $"{versionPrefix}/changed.zip";
|
var changedZipKey = $"{versionPrefix}/changed.zip";
|
||||||
var changedFolderKey = $"{versionPrefix}/{changedFolderName}";
|
var changedFolderKey = $"{versionPrefix}/{changedFolderName}";
|
||||||
var filesZipKey = $"{versionPrefix}/Files.zip";
|
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(changedZipPath, changedZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
|
||||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filesZipPath, filesZipKey, "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
|
var updatedManifest = manifest with
|
||||||
{
|
{
|
||||||
|
Checksums = updatedChecksums,
|
||||||
Downloads = new PlondsDownloadInfo(
|
Downloads = new PlondsDownloadInfo(
|
||||||
ReleaseTag: releaseTag,
|
ReleaseTag: releaseTag,
|
||||||
GitHub: new PlondsGitHubDownloadInfo(
|
GitHub: new PlondsGitHubDownloadInfo(
|
||||||
@@ -92,8 +101,10 @@ public sealed class PlondsPublisher
|
|||||||
|
|
||||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(updatedManifest, JsonOptions), new UTF8Encoding(false));
|
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, 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(manifestKey, cancellationToken).ConfigureAwait(false);
|
||||||
|
await s3.EnsureObjectExistsAsync(latestManifestKey, cancellationToken).ConfigureAwait(false);
|
||||||
await s3.EnsureObjectExistsAsync(changedZipKey, cancellationToken).ConfigureAwait(false);
|
await s3.EnsureObjectExistsAsync(changedZipKey, cancellationToken).ConfigureAwait(false);
|
||||||
await s3.EnsureObjectExistsAsync(filesZipKey, 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.");
|
?? 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)
|
private static string NormalizePrefix(string value)
|
||||||
{
|
{
|
||||||
var normalized = Require(value, nameof(value)).Replace('\\', '/').Trim('/');
|
var normalized = Require(value, nameof(value)).Replace('\\', '/').Trim('/');
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ public sealed record PlondsManifest(
|
|||||||
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||||
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||||
IReadOnlyDictionary<string, string> Checksums,
|
IReadOnlyDictionary<string, string> Checksums,
|
||||||
PlondsDownloadInfo? Downloads = null);
|
PlondsDownloadInfo? Downloads = null,
|
||||||
|
IReadOnlyList<PlondsSourceDescriptor>? Sources = null);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Plonds.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record PlondsSourceDescriptor(
|
||||||
|
string Id,
|
||||||
|
string Kind,
|
||||||
|
string ManifestUrl,
|
||||||
|
int Priority = 0);
|
||||||
Reference in New Issue
Block a user