mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat.PLONDS客户端
This commit is contained in:
@@ -113,6 +113,7 @@ Publisher 上传到 S3 的版本目录:
|
||||
|
||||
- `Files.zip` 是上传到 S3 时的完整包标准名。
|
||||
- `<version>-Files/` 是 S3 上解压后的完整包目录。
|
||||
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
|
||||
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。
|
||||
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
||||
|
||||
|
||||
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.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Plonds;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
@@ -103,35 +104,74 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsUpdateManifestProvider_UsesSelectedUpdateSource()
|
||||
public async Task UpdateSettingsService_WhenPlondsSelected_UsesPlondsServiceWithoutCreatingOrchestrator()
|
||||
{
|
||||
var update = new FakeUpdateSettingsService
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
State = DefaultUpdateState() with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub }
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
|
||||
}
|
||||
};
|
||||
var plonds = new FakeManifestProvider("plonds");
|
||||
var github = new FakeManifestProvider("github");
|
||||
var provider = new SettingsUpdateManifestProvider(new FakeSettingsFacade(update), plonds, github);
|
||||
|
||||
var manifest = await provider.GetLatestAsync(
|
||||
UpdateSettingsValues.ChannelStable,
|
||||
"windows-x64",
|
||||
var plonds = new FakePlondsService
|
||||
{
|
||||
LatestResult = PlondsLatestResult.Available(
|
||||
new Version(1, 0, 0),
|
||||
CancellationToken.None);
|
||||
new Version(9, 9, 9),
|
||||
[new PlondsManifestCandidate(
|
||||
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
CreatePlondsManifest("9.9.9"))])
|
||||
};
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS.");
|
||||
},
|
||||
plondsService: plonds);
|
||||
|
||||
Assert.Equal("github", manifest?.DistributionId);
|
||||
Assert.Equal(0, plonds.GetLatestCalls);
|
||||
Assert.Equal(1, github.GetLatestCalls);
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds };
|
||||
manifest = await provider.GetLatestAsync(
|
||||
UpdateSettingsValues.ChannelStable,
|
||||
"windows-x64",
|
||||
new Version(1, 0, 0),
|
||||
CancellationToken.None);
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
Assert.Equal("9.9.9", report.LatestVersion);
|
||||
Assert.Equal(1, plonds.FindLatestCalls);
|
||||
Assert.False(orchestratorCreated);
|
||||
}
|
||||
|
||||
Assert.Equal("plonds", manifest?.DistributionId);
|
||||
Assert.Equal(1, plonds.GetLatestCalls);
|
||||
[Fact]
|
||||
public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator()
|
||||
{
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||
}
|
||||
};
|
||||
var orchestrator = CreateTestOrchestrator(DefaultUpdateState() with
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||
});
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
return orchestrator;
|
||||
},
|
||||
plondsService: new FakePlondsService());
|
||||
|
||||
var _ = service.CurrentPhase;
|
||||
|
||||
Assert.False(orchestratorCreated);
|
||||
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(orchestratorCreated);
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -177,6 +217,33 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
LastUpdateCheckUtcMs: null,
|
||||
PendingUpdateSha256: null);
|
||||
|
||||
private static UpdateOrchestrator CreateTestOrchestrator(SettingsUpdateState state)
|
||||
{
|
||||
return new UpdateOrchestrator(
|
||||
new FakeManifestProvider("github"),
|
||||
new UpdateDownloadEngine(new FakeManifestProvider("github"), new ResumableDownloadService(new HttpClient(new EmptyHandler()))),
|
||||
new UpdateInstallGateway(),
|
||||
new UpdateStateStore(new FakeSettingsFacade(new FakeUpdateSettingsService { State = state })));
|
||||
}
|
||||
|
||||
private static PlondsClientManifest CreatePlondsManifest(string version)
|
||||
{
|
||||
return new PlondsClientManifest(
|
||||
FormatVersion: "2.0",
|
||||
CurrentVersion: version,
|
||||
PreviousVersion: "1.0.0",
|
||||
IsFullUpdate: false,
|
||||
RequiresCleanInstall: false,
|
||||
Channel: "stable",
|
||||
Platform: "windows-x64",
|
||||
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),
|
||||
FilesMap: new Dictionary<string, PlondsClientFileEntry>(),
|
||||
ChangedFilesMap: new Dictionary<string, PlondsClientChangedFileEntry>(),
|
||||
Checksums: new Dictionary<string, string>(),
|
||||
Downloads: null,
|
||||
Sources: []);
|
||||
}
|
||||
|
||||
private sealed class FakeUpdateSettingsService : IUpdateSettingsService
|
||||
{
|
||||
public SettingsUpdateState State { get; set; } = DefaultUpdateState();
|
||||
@@ -263,9 +330,6 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default)
|
||||
=> CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
public Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PlondsUpdatePayload?>(null);
|
||||
|
||||
public Task<LanMountainDesktop.Services.UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -285,6 +349,115 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
=> Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false));
|
||||
}
|
||||
|
||||
private sealed class FakePlondsService : IPlondsService
|
||||
{
|
||||
public PlondsLatestResult LatestResult { get; set; } = PlondsLatestResult.UpToDate(new Version(1, 0, 0), new Version(1, 0, 0));
|
||||
public PlondsPrepareResult PrepareResult { get; set; } = PlondsPrepareResult.FailedForUi("not prepared");
|
||||
public int FindLatestCalls { get; private set; }
|
||||
public int PrepareLatestCalls { get; private set; }
|
||||
|
||||
public Task<PlondsLatestResult> FindLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
FindLatestCalls++;
|
||||
return Task.FromResult(LatestResult);
|
||||
}
|
||||
|
||||
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareLatestCalls++;
|
||||
return Task.FromResult(PrepareResult);
|
||||
}
|
||||
|
||||
public Task<PlondsPrepareResult> FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareLatestCalls++;
|
||||
return Task.FromResult(PrepareResult);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsService : ISettingsService
|
||||
{
|
||||
public event EventHandler<SettingsChangedEvent>? Changed;
|
||||
|
||||
public AppSettingsSnapshot Snapshot { get; init; } = new();
|
||||
|
||||
public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new()
|
||||
{
|
||||
if (typeof(T) == typeof(AppSettingsSnapshot))
|
||||
{
|
||||
return (T)(object)Snapshot.Clone();
|
||||
}
|
||||
|
||||
return new T();
|
||||
}
|
||||
|
||||
public void SaveSnapshot<T>(
|
||||
SettingsScope scope,
|
||||
T snapshot,
|
||||
string? subjectId = null,
|
||||
string? placementId = null,
|
||||
string? sectionId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
if (snapshot is AppSettingsSnapshot appSettings)
|
||||
{
|
||||
CopyUpdateSettings(appSettings, Snapshot);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
|
||||
}
|
||||
|
||||
public T LoadSection<T>(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new()
|
||||
=> new();
|
||||
|
||||
public void SaveSection<T>(
|
||||
SettingsScope scope,
|
||||
string subjectId,
|
||||
string sectionId,
|
||||
T section,
|
||||
string? placementId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
|
||||
{
|
||||
}
|
||||
|
||||
public T? GetValue<T>(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null)
|
||||
=> default;
|
||||
|
||||
public void SetValue<T>(
|
||||
SettingsScope scope,
|
||||
string key,
|
||||
T value,
|
||||
string? subjectId = null,
|
||||
string? placementId = null,
|
||||
string? sectionId = null,
|
||||
IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
private static void CopyUpdateSettings(AppSettingsSnapshot source, AppSettingsSnapshot target)
|
||||
{
|
||||
target.IncludePrereleaseUpdates = source.IncludePrereleaseUpdates;
|
||||
target.UpdateChannel = source.UpdateChannel;
|
||||
target.UpdateMode = source.UpdateMode;
|
||||
target.UpdateDownloadSource = source.UpdateDownloadSource;
|
||||
target.UpdateDownloadThreads = source.UpdateDownloadThreads;
|
||||
target.ForceUpdateReinstall = source.ForceUpdateReinstall;
|
||||
target.UseGhProxyMirror = source.UseGhProxyMirror;
|
||||
target.PendingUpdateInstallerPath = source.PendingUpdateInstallerPath;
|
||||
target.PendingUpdateVersion = source.PendingUpdateVersion;
|
||||
target.PendingUpdatePublishedAtUtcMs = source.PendingUpdatePublishedAtUtcMs;
|
||||
target.LastUpdateCheckUtcMs = source.LastUpdateCheckUtcMs;
|
||||
target.PendingUpdateSha256 = source.PendingUpdateSha256;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider
|
||||
{
|
||||
public string ProviderName { get; } = providerName;
|
||||
@@ -318,6 +491,14 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
private sealed class EmptyHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService
|
||||
{
|
||||
public ISettingsService Settings => throw new NotSupportedException();
|
||||
|
||||
@@ -34,20 +34,7 @@ public sealed record UpdateCheckResult(
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage,
|
||||
bool ForceMode = false,
|
||||
PlondsUpdatePayload? PlondsPayload = null);
|
||||
|
||||
public sealed record PlondsUpdatePayload(
|
||||
string DistributionId,
|
||||
string ChannelId,
|
||||
string SubChannel,
|
||||
string? FileMapJson,
|
||||
string? FileMapSignature,
|
||||
string? FileMapJsonUrl,
|
||||
string? FileMapSignatureUrl,
|
||||
string? UpdateArchiveUrl = null,
|
||||
string? UpdateArchiveSha256 = null,
|
||||
long? UpdateArchiveSizeBytes = null);
|
||||
bool ForceMode = false);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
@@ -162,10 +149,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
var preferredAsset = isUpdateAvailable
|
||||
? SelectPreferredInstallerAsset(release.Assets)
|
||||
: null;
|
||||
var plondsPayload = isUpdateAvailable
|
||||
? TryResolvePlondsPayload(release)
|
||||
: null;
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: isUpdateAvailable,
|
||||
@@ -173,8 +156,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
PlondsPayload: plondsPayload);
|
||||
ErrorMessage: null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -239,8 +221,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
: release.TagName;
|
||||
|
||||
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||
var plondsPayload = TryResolvePlondsPayload(release);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
@@ -249,8 +229,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: true,
|
||||
PlondsPayload: plondsPayload);
|
||||
ForceMode: true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -703,46 +682,6 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||
{
|
||||
if (release.Assets is null || release.Assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var platformSuffix = GetPlatformAssetSuffix();
|
||||
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
||||
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
||||
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
||||
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
||||
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
||||
var channelId = release.IsPrerelease
|
||||
? UpdateSettingsValues.ChannelPreview
|
||||
: UpdateSettingsValues.ChannelStable;
|
||||
|
||||
return new PlondsUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: channelId,
|
||||
SubChannel: platformSuffix,
|
||||
FileMapJson: null,
|
||||
FileMapSignature: null,
|
||||
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
||||
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveSha256: archiveAsset.Sha256,
|
||||
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
||||
{
|
||||
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetPlatformAssetSuffix()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
|
||||
@@ -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();
|
||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
|
||||
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
|
||||
@@ -10,6 +10,7 @@ using Avalonia.Media.Imaging;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Plonds;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Services.PluginMarket;
|
||||
@@ -788,44 +789,57 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly PlondsStaticUpdateService _plondsStaticUpdateService = new();
|
||||
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
|
||||
private readonly IPlondsService _plondsService;
|
||||
private readonly PlondsPreparedPackageInstaller _plondsInstaller = new();
|
||||
private readonly Lazy<UpdateOrchestrator> _orchestrator;
|
||||
private PlondsLatestResult? _pendingPlondsLatest;
|
||||
private PlondsPreparedPackage? _pendingPlondsPackage;
|
||||
private UpdatePhase _plondsPhase = UpdatePhase.Idle;
|
||||
private bool _orchestratorEventsSubscribed;
|
||||
|
||||
public UpdateSettingsService(ISettingsService settingsService, Func<UpdateOrchestrator>? orchestratorFactory = null)
|
||||
public UpdateSettingsService(
|
||||
ISettingsService settingsService,
|
||||
Func<UpdateOrchestrator>? orchestratorFactory = null,
|
||||
IPlondsService? plondsService = null)
|
||||
{
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
_plondsService = plondsService ?? PlondsClientServiceFactory.CreateDefault();
|
||||
_orchestrator = new Lazy<UpdateOrchestrator>(
|
||||
orchestratorFactory ?? HostUpdateOrchestratorProvider.GetOrCreate,
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public UpdatePhase CurrentPhase => _orchestrator.Value.CurrentPhase;
|
||||
public UpdatePhase CurrentPhase => IsPlondsSelected()
|
||||
? _plondsPhase
|
||||
: (_orchestrator.IsValueCreated ? _orchestrator.Value.CurrentPhase : UpdatePhase.Idle);
|
||||
|
||||
public event Action<UpdatePhase>? PhaseChanged
|
||||
{
|
||||
add => _orchestrator.Value.PhaseChanged += value;
|
||||
add
|
||||
{
|
||||
_phaseChanged += value;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (_orchestrator.IsValueCreated)
|
||||
{
|
||||
_orchestrator.Value.PhaseChanged -= value;
|
||||
}
|
||||
_phaseChanged -= value;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<UpdateProgressReport>? ProgressChanged
|
||||
{
|
||||
add => _orchestrator.Value.ProgressChanged += value;
|
||||
add
|
||||
{
|
||||
_progressChanged += value;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (_orchestrator.IsValueCreated)
|
||||
{
|
||||
_orchestrator.Value.ProgressChanged -= value;
|
||||
}
|
||||
_progressChanged -= value;
|
||||
}
|
||||
}
|
||||
|
||||
private event Action<UpdatePhase>? _phaseChanged;
|
||||
private event Action<UpdateProgressReport>? _progressChanged;
|
||||
|
||||
public UpdateSettingsState Get()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
@@ -900,47 +914,75 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
|
||||
public Task<UpdateCheckReport> CheckAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.CheckAsync(cancellationToken);
|
||||
return IsPlondsSelected()
|
||||
? CheckPlondsAsync(cancellationToken)
|
||||
: GetOrchestrator().CheckAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<LanMountainDesktop.Services.Update.DownloadResult> DownloadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.DownloadAsync(cancellationToken);
|
||||
return IsPlondsSelected()
|
||||
? DownloadPlondsAsync(cancellationToken)
|
||||
: GetOrchestrator().DownloadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<InstallResult> InstallAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.InstallAsync(cancellationToken);
|
||||
return IsPlondsSelected()
|
||||
? InstallPlondsAsync(cancellationToken)
|
||||
: GetOrchestrator().InstallAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task RollbackAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.RollbackAsync(cancellationToken);
|
||||
return GetOrchestrator().RollbackAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task PauseAsync()
|
||||
{
|
||||
return _orchestrator.Value.PauseAsync();
|
||||
return IsPlondsSelected()
|
||||
? PausePlondsAsync()
|
||||
: GetOrchestrator().PauseAsync();
|
||||
}
|
||||
|
||||
public Task<LanMountainDesktop.Services.Update.DownloadResult> ResumeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.ResumeAsync(cancellationToken);
|
||||
return IsPlondsSelected()
|
||||
? ResumePlondsAsync(cancellationToken)
|
||||
: GetOrchestrator().ResumeAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task CancelAsync()
|
||||
{
|
||||
return _orchestrator.Value.CancelAsync();
|
||||
if (IsPlondsSelected())
|
||||
{
|
||||
_pendingPlondsLatest = null;
|
||||
_pendingPlondsPackage = null;
|
||||
TransitionPlonds(UpdatePhase.Idle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return GetOrchestrator().CancelAsync();
|
||||
}
|
||||
|
||||
public Task AutoCheckIfEnabledAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _orchestrator.Value.AutoCheckIfEnabledAsync(cancellationToken);
|
||||
if (IsPlondsSelected())
|
||||
{
|
||||
return AutoCheckPlondsIfEnabledAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return GetOrchestrator().AutoCheckIfEnabledAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public bool TryApplyOnExit()
|
||||
{
|
||||
return _orchestrator.Value.TryApplyOnExit();
|
||||
if (IsPlondsSelected())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return GetOrchestrator().TryApplyOnExit();
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||
@@ -959,26 +1001,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staticResult = isForce
|
||||
? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
if (staticResult.Success && staticResult.PlondsPayload is not null)
|
||||
{
|
||||
return staticResult.PlondsPayload;
|
||||
}
|
||||
|
||||
var result = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return result.Success ? result.PlondsPayload : null;
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
@@ -1016,8 +1038,11 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
public void Dispose()
|
||||
{
|
||||
_githubReleaseUpdateService.Dispose();
|
||||
_plondsStaticUpdateService.Dispose();
|
||||
_plondsReleaseUpdateService.Dispose();
|
||||
if (_orchestrator.IsValueCreated && _orchestratorEventsSubscribed)
|
||||
{
|
||||
_orchestrator.Value.PhaseChanged -= OnOrchestratorPhaseChanged;
|
||||
_orchestrator.Value.ProgressChanged -= OnOrchestratorProgressChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
|
||||
@@ -1026,59 +1051,240 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool isForce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = UpdateSettingsValues.NormalizeDownloadSource(Get().UpdateDownloadSource);
|
||||
if (string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
if (IsGitHubSelected())
|
||||
{
|
||||
return isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
}
|
||||
|
||||
var staticResult = isForce
|
||||
? await _plondsStaticUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsStaticUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (staticResult.Success)
|
||||
{
|
||||
return staticResult;
|
||||
var result = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
return new UpdateCheckResult(
|
||||
Success: result.Success,
|
||||
IsUpdateAvailable: isForce || result.IsUpdateAvailable,
|
||||
CurrentVersionText: currentVersion.ToString(),
|
||||
LatestVersionText: result.LatestVersion?.ToString() ?? "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS static update check failed and will fallback to GitHub release PLONDS. Error: {staticResult.ErrorMessage}");
|
||||
|
||||
var plondsResult = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (plondsResult.Success)
|
||||
private async Task<UpdateCheckReport> CheckPlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return plondsResult;
|
||||
if (!_plondsPhase.CanCheck())
|
||||
{
|
||||
return new UpdateCheckReport(false, null, null, null, null, null, null, null, null, $"Cannot check in phase {_plondsPhase}.");
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
||||
|
||||
var githubFallbackResult = isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (githubFallbackResult.Success)
|
||||
TransitionPlonds(UpdatePhase.Checking);
|
||||
var currentVersionText = LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider.ResolveForCurrentProcess().Version;
|
||||
if (!TryParseVersion(currentVersionText, out var currentVersion))
|
||||
{
|
||||
AppLogger.Info(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, $"Invalid current version text: {currentVersionText}");
|
||||
}
|
||||
|
||||
return githubFallbackResult;
|
||||
var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
_pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null;
|
||||
_pendingPlondsPackage = null;
|
||||
TransitionPlonds(UpdatePhase.Checked);
|
||||
SaveLastChecked();
|
||||
|
||||
if (!latest.Success)
|
||||
{
|
||||
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
|
||||
}
|
||||
|
||||
return new UpdateCheckReport(
|
||||
latest.IsUpdateAvailable,
|
||||
latest.LatestVersion?.ToString(),
|
||||
currentVersionText,
|
||||
latest.IsUpdateAvailable ? UpdatePayloadKind.DeltaPlonds : null,
|
||||
latest.Candidates.FirstOrDefault()?.Source.Id,
|
||||
Get().UpdateChannel,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
private async Task<LanMountainDesktop.Services.Update.DownloadResult> DownloadPlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_plondsPhase is not UpdatePhase.Checked)
|
||||
{
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(false, null, $"Cannot download in phase {_plondsPhase}.", false);
|
||||
}
|
||||
|
||||
if (_pendingPlondsLatest is null || !_pendingPlondsLatest.IsUpdateAvailable)
|
||||
{
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(false, null, "No PLONDS update is pending.", false);
|
||||
}
|
||||
|
||||
TransitionPlonds(UpdatePhase.Downloading);
|
||||
var currentVersion = _pendingPlondsLatest.CurrentVersion;
|
||||
var result = await _plondsService.FindAndPrepareLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.Success || result.Package is null)
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(false, null, result.ErrorMessage ?? "PLONDS package preparation failed.", false);
|
||||
}
|
||||
|
||||
_pendingPlondsPackage = result.Package;
|
||||
TransitionPlonds(UpdatePhase.Downloaded);
|
||||
SavePendingPlondsPackage(result.Package);
|
||||
return new LanMountainDesktop.Services.Update.DownloadResult(true, result.Package.ManifestPath, null, true);
|
||||
}
|
||||
|
||||
private Task PausePlondsAsync()
|
||||
{
|
||||
if (_plondsPhase.CanPause())
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.PausedDownloading);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<LanMountainDesktop.Services.Update.DownloadResult> ResumePlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _plondsPhase is UpdatePhase.PausedDownloading
|
||||
? await DownloadPlondsAsync(cancellationToken).ConfigureAwait(false)
|
||||
: new LanMountainDesktop.Services.Update.DownloadResult(false, null, $"Cannot resume in phase {_plondsPhase}.", false);
|
||||
}
|
||||
|
||||
private async Task<InstallResult> InstallPlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_plondsPhase.CanInstall())
|
||||
{
|
||||
return new InstallResult(false, $"Cannot install in phase {_plondsPhase}.", false, "invalid_phase");
|
||||
}
|
||||
|
||||
if (_pendingPlondsPackage is null)
|
||||
{
|
||||
return new InstallResult(false, "No PLONDS package has been prepared.", false, "staging_incomplete");
|
||||
}
|
||||
|
||||
TransitionPlonds(UpdatePhase.Installing);
|
||||
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||
var progress = new Progress<InstallProgressReport>(report =>
|
||||
{
|
||||
_progressChanged?.Invoke(new UpdateProgressReport(
|
||||
UpdatePhase.Installing,
|
||||
report.Message,
|
||||
report.ProgressPercent / 100.0,
|
||||
null,
|
||||
report));
|
||||
});
|
||||
|
||||
var install = await _plondsInstaller.InstallAsync(_pendingPlondsPackage, launcherRoot, progress, cancellationToken).ConfigureAwait(false);
|
||||
if (!install.Success)
|
||||
{
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new InstallResult(false, install.ErrorMessage, false, install.ErrorCode);
|
||||
}
|
||||
|
||||
TransitionPlonds(UpdatePhase.Installed);
|
||||
return new InstallResult(true, null, false);
|
||||
}
|
||||
|
||||
private async Task AutoCheckPlondsIfEnabledAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = Get();
|
||||
if (string.Equals(UpdateSettingsValues.NormalizeMode(settings.UpdateMode), UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await CheckPlondsAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (report.IsUpdateAvailable && _plondsPhase.CanDownload())
|
||||
{
|
||||
await DownloadPlondsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPlondsSelected()
|
||||
{
|
||||
return !IsGitHubSelected();
|
||||
}
|
||||
|
||||
private bool IsGitHubSelected()
|
||||
{
|
||||
var source = UpdateSettingsValues.NormalizeDownloadSource(Get().UpdateDownloadSource);
|
||||
return string.Equals(source, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(source, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void TransitionPlonds(UpdatePhase phase)
|
||||
{
|
||||
if (_plondsPhase == phase)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_plondsPhase = phase;
|
||||
_phaseChanged?.Invoke(phase);
|
||||
_progressChanged?.Invoke(new UpdateProgressReport(phase, $"Phase changed to {phase}", 0, null, null));
|
||||
}
|
||||
|
||||
private UpdateOrchestrator GetOrchestrator()
|
||||
{
|
||||
var orchestrator = _orchestrator.Value;
|
||||
if (!_orchestratorEventsSubscribed)
|
||||
{
|
||||
orchestrator.PhaseChanged += OnOrchestratorPhaseChanged;
|
||||
orchestrator.ProgressChanged += OnOrchestratorProgressChanged;
|
||||
_orchestratorEventsSubscribed = true;
|
||||
}
|
||||
|
||||
return orchestrator;
|
||||
}
|
||||
|
||||
private void OnOrchestratorPhaseChanged(UpdatePhase phase)
|
||||
{
|
||||
_phaseChanged?.Invoke(phase);
|
||||
}
|
||||
|
||||
private void OnOrchestratorProgressChanged(UpdateProgressReport report)
|
||||
{
|
||||
_progressChanged?.Invoke(report);
|
||||
}
|
||||
|
||||
private void SaveLastChecked()
|
||||
{
|
||||
var state = Get();
|
||||
Save(state with { LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
|
||||
}
|
||||
|
||||
private void SavePendingPlondsPackage(PlondsPreparedPackage package)
|
||||
{
|
||||
var state = Get();
|
||||
Save(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = package.ManifestPath,
|
||||
PendingUpdateVersion = package.Version.ToString(),
|
||||
PendingUpdatePublishedAtUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string? value, out Version version)
|
||||
{
|
||||
version = new Version(0, 0, 0);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().TrimStart('v', 'V');
|
||||
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
normalized = normalized[..separatorIndex];
|
||||
}
|
||||
|
||||
return Version.TryParse(normalized, out version!);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Services.Update;
|
||||
|
||||
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider, IDisposable
|
||||
{
|
||||
private readonly GitHubReleaseUpdateService _githubService;
|
||||
private readonly bool _ownsService;
|
||||
@@ -37,7 +37,7 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
return null;
|
||||
}
|
||||
|
||||
return UpdateManifestMapper.FromGitHubRelease(result.Release, result.PlondsPayload, channel, platform);
|
||||
return UpdateManifestMapper.FromGitHubRelease(result.Release, channel, platform);
|
||||
}
|
||||
|
||||
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||
@@ -53,8 +53,7 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
return null;
|
||||
}
|
||||
|
||||
var plondsPayload = TryResolvePlondsPayload(release);
|
||||
return UpdateManifestMapper.FromGitHubRelease(release, plondsPayload, channel, platform);
|
||||
return UpdateManifestMapper.FromGitHubRelease(release, channel, platform);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||
@@ -67,65 +66,11 @@ internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
||||
}
|
||||
|
||||
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||
public void Dispose()
|
||||
{
|
||||
if (release.Assets is null || release.Assets.Count == 0)
|
||||
if (_ownsService)
|
||||
{
|
||||
return null;
|
||||
_githubService.Dispose();
|
||||
}
|
||||
|
||||
var platformSuffix = GetPlatformAssetSuffix();
|
||||
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
||||
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
||||
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
||||
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
||||
|
||||
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
||||
var channelId = release.IsPrerelease
|
||||
? UpdateSettingsValues.ChannelPreview
|
||||
: UpdateSettingsValues.ChannelStable;
|
||||
|
||||
return new PlondsUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: channelId,
|
||||
SubChannel: platformSuffix,
|
||||
FileMapJson: null,
|
||||
FileMapSignature: null,
|
||||
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
||||
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
||||
UpdateArchiveSha256: archiveAsset.Sha256,
|
||||
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
||||
{
|
||||
return assets.FirstOrDefault(a => string.Equals(a.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string GetPlatformAssetSuffix()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
|
||||
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
System.Runtime.InteropServices.Architecture.X86 => "x86",
|
||||
System.Runtime.InteropServices.Architecture.Arm => "arm",
|
||||
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
|
||||
_ => "x64"
|
||||
};
|
||||
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
GitHubReleaseInfo release,
|
||||
PlondsUpdatePayload? plondsPayload,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
if (plondsPayload is not null)
|
||||
{
|
||||
return FromPlondsPayload(plondsPayload, release, channel, platform);
|
||||
}
|
||||
|
||||
return FromFullInstaller(release, channel, platform);
|
||||
}
|
||||
|
||||
public static UpdateManifest FromPlondsPayload(
|
||||
PlondsUpdatePayload payload,
|
||||
GitHubReleaseInfo release,
|
||||
string channel,
|
||||
string platform)
|
||||
{
|
||||
var files = new List<UpdateFileEntry>();
|
||||
|
||||
if (payload.UpdateArchiveUrl is not null)
|
||||
{
|
||||
files.Add(new UpdateFileEntry(
|
||||
Path: "update.zip",
|
||||
Action: "add",
|
||||
Sha256: payload.UpdateArchiveSha256 ?? string.Empty,
|
||||
Size: payload.UpdateArchiveSizeBytes ?? 0,
|
||||
Mode: "compressed-object",
|
||||
ObjectKey: null,
|
||||
ObjectUrl: payload.UpdateArchiveUrl,
|
||||
ArchiveSha256: null,
|
||||
Metadata: null));
|
||||
}
|
||||
|
||||
var mirrors = release.Assets
|
||||
.Where(IsInstallerAsset)
|
||||
.Select(a => new UpdateMirrorAsset(
|
||||
Platform: platform,
|
||||
Url: a.BrowserDownloadUrl,
|
||||
Name: a.Name,
|
||||
Sha256: a.Sha256,
|
||||
Size: a.SizeBytes))
|
||||
.ToArray();
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["source"] = "github-plonds",
|
||||
["releaseTag"] = release.TagName
|
||||
};
|
||||
|
||||
return new UpdateManifest(
|
||||
DistributionId: payload.DistributionId,
|
||||
FromVersion: string.Empty,
|
||||
ToVersion: NormalizeTagVersion(release.TagName),
|
||||
Platform: platform,
|
||||
Channel: channel,
|
||||
PublishedAt: release.PublishedAt,
|
||||
Kind: UpdatePayloadKind.DeltaPlonds,
|
||||
FileMapUrl: payload.FileMapJsonUrl,
|
||||
FileMapSignatureUrl: payload.FileMapSignatureUrl,
|
||||
FileMapSha256: null,
|
||||
Files: files,
|
||||
InstallerMirrors: mirrors,
|
||||
Metadata: metadata);
|
||||
}
|
||||
string platform) => FromFullInstaller(release, channel, platform);
|
||||
|
||||
public static UpdateManifest FromFullInstaller(
|
||||
GitHubReleaseInfo release,
|
||||
|
||||
@@ -25,8 +25,7 @@ internal static class HostUpdateOrchestratorProvider
|
||||
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
var githubProvider = new GithubReleaseManifestProvider("wwiinnddyy", "LanMountainDesktop");
|
||||
var plondsProvider = new PlondsApiManifestProvider("https://api.classisland.tech");
|
||||
var manifestProvider = new SettingsUpdateManifestProvider(settingsFacade, plondsProvider, githubProvider);
|
||||
var manifestProvider = githubProvider;
|
||||
var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
var downloadEngine = new UpdateDownloadEngine(manifestProvider, new ResumableDownloadService(httpClient));
|
||||
var installGateway = new UpdateInstallGateway();
|
||||
@@ -128,7 +127,7 @@ public sealed class UpdateOrchestrator : IDisposable
|
||||
UpdateManifest? manifest;
|
||||
try
|
||||
{
|
||||
var platform = LanMountainDesktop.Services.PlondsStaticUpdateService.ResolveCurrentPlatform();
|
||||
var platform = ResolveCurrentPlatform();
|
||||
manifest = settings.ForceUpdateReinstall
|
||||
? await _manifestProvider.GetByVersionAsync(
|
||||
currentVersionText,
|
||||
@@ -711,6 +710,24 @@ public sealed class UpdateOrchestrator : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ResolveCurrentPlatform()
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? "windows"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: OperatingSystem.IsMacOS()
|
||||
? "macos"
|
||||
: "unknown";
|
||||
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
|
||||
System.Runtime.InteropServices.Architecture.X86 => "x86",
|
||||
_ => "x64"
|
||||
};
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
|
||||
private void OnPhaseChanged(UpdatePhase phase)
|
||||
{
|
||||
PhaseChanged?.Invoke(phase);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Plonds.Shared.Models;
|
||||
@@ -53,6 +54,7 @@ public sealed class PlondsPublisher
|
||||
ZipFile.ExtractToDirectory(filesZipPath, filesExtractRoot, overwriteFiles: true);
|
||||
|
||||
var manifestKey = $"{versionPrefix}/PLONDS.json";
|
||||
var latestManifestKey = $"{prefix}/PLONDS.json";
|
||||
var changedZipKey = $"{versionPrefix}/changed.zip";
|
||||
var changedFolderKey = $"{versionPrefix}/{changedFolderName}";
|
||||
var filesZipKey = $"{versionPrefix}/Files.zip";
|
||||
@@ -66,8 +68,15 @@ public sealed class PlondsPublisher
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(changedZipPath, changedZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filesZipPath, filesZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var updatedChecksums = new Dictionary<string, string>(manifest.Checksums, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["changed.zip"] = NormalizeChecksum(manifest.Checksums, "changed.zip", changedZipPath),
|
||||
["Files.zip"] = $"md5:{ComputeMd5Hex(filesZipPath)}"
|
||||
};
|
||||
|
||||
var updatedManifest = manifest with
|
||||
{
|
||||
Checksums = updatedChecksums,
|
||||
Downloads = new PlondsDownloadInfo(
|
||||
ReleaseTag: releaseTag,
|
||||
GitHub: new PlondsGitHubDownloadInfo(
|
||||
@@ -92,8 +101,10 @@ public sealed class PlondsPublisher
|
||||
|
||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(updatedManifest, JsonOptions), new UTF8Encoding(false));
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(manifestPath, manifestKey, "application/json"), cancellationToken).ConfigureAwait(false);
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(manifestPath, latestManifestKey, "application/json"), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await s3.EnsureObjectExistsAsync(manifestKey, cancellationToken).ConfigureAwait(false);
|
||||
await s3.EnsureObjectExistsAsync(latestManifestKey, cancellationToken).ConfigureAwait(false);
|
||||
await s3.EnsureObjectExistsAsync(changedZipKey, cancellationToken).ConfigureAwait(false);
|
||||
await s3.EnsureObjectExistsAsync(filesZipKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -140,6 +151,22 @@ public sealed class PlondsPublisher
|
||||
?? throw new InvalidOperationException("PLONDS manifest is empty or invalid.");
|
||||
}
|
||||
|
||||
private static string NormalizeChecksum(
|
||||
IReadOnlyDictionary<string, string> checksums,
|
||||
string key,
|
||||
string filePath)
|
||||
{
|
||||
return checksums.TryGetValue(key, out var checksum) && !string.IsNullOrWhiteSpace(checksum)
|
||||
? checksum
|
||||
: $"md5:{ComputeMd5Hex(filePath)}";
|
||||
}
|
||||
|
||||
private static string ComputeMd5Hex(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizePrefix(string value)
|
||||
{
|
||||
var normalized = Require(value, nameof(value)).Replace('\\', '/').Trim('/');
|
||||
|
||||
@@ -12,4 +12,5 @@ public sealed record PlondsManifest(
|
||||
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||
IReadOnlyDictionary<string, string> Checksums,
|
||||
PlondsDownloadInfo? Downloads = null);
|
||||
PlondsDownloadInfo? Downloads = null,
|
||||
IReadOnlyList<PlondsSourceDescriptor>? Sources = null);
|
||||
|
||||
@@ -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