mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-07-01 07:34:26 +08:00
Compare commits
2 Commits
131043fe37
...
03e4442e74
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e4442e74 | ||
|
|
0c8830133a |
6
.github/workflows/plonds-uploader.yml
vendored
6
.github/workflows/plonds-uploader.yml
vendored
@@ -75,9 +75,10 @@ jobs:
|
||||
set -euo pipefail
|
||||
rm -rf plonds-assets
|
||||
mkdir -p plonds-assets
|
||||
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -D plonds-assets --clobber
|
||||
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -p files-windows-x64.zip -D plonds-assets --clobber
|
||||
test -f plonds-assets/changed.zip
|
||||
test -f plonds-assets/PLONDS.json
|
||||
test -f plonds-assets/files-windows-x64.zip
|
||||
jq -e . plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
- name: Publish PLONDS assets to Rainyun S3
|
||||
@@ -106,6 +107,7 @@ jobs:
|
||||
--repository "${{ github.repository }}" \
|
||||
--manifest "$PWD/plonds-assets/PLONDS.json" \
|
||||
--changed-zip "$PWD/plonds-assets/changed.zip" \
|
||||
--files-zip "$PWD/plonds-assets/files-windows-x64.zip" \
|
||||
--work-dir "$PWD/plonds-publish-work" \
|
||||
--s3-prefix "$PLONDS_S3_PREFIX" \
|
||||
--s3-endpoint "$S3_ENDPOINT" \
|
||||
@@ -116,7 +118,7 @@ jobs:
|
||||
--s3-public-base-url "$PUBLIC_BASE" \
|
||||
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX"
|
||||
|
||||
jq -e '.downloads.github.changedZipUrl and .downloads.s3.changedFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||
jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
- name: Upload enriched PLONDS manifest to GitHub Release
|
||||
env:
|
||||
|
||||
171
.trae/specs/plonds-client-service/spec.md
Normal file
171
.trae/specs/plonds-client-service/spec.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# PLONDS Client Service 独立化设计
|
||||
|
||||
> 日期:2026-06-01
|
||||
> 状态:设计中
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PLONDS 在应用内必须作为独立服务存在,负责分发发现、下载、校验和本地包准备。它不是现有 Update 模块的 provider,也不应把 S3/GitHub/source 选择逻辑混入 `LanMountainDesktop/Services/Update/`。
|
||||
|
||||
最终边界:
|
||||
|
||||
- PLONDS 服务:寻找最新版本、选择下载源、下载 manifest 和包、校验文件、准备本地 staging。
|
||||
- 安装程序/安装网关:只消费 PLONDS 已准备好的本地安装输入,执行增量安装或完整安装。
|
||||
- UI:只展示 PLONDS 服务和安装程序返回的状态;完整包也失败后才处理错误。
|
||||
|
||||
## 2. 当前耦合点
|
||||
|
||||
当前需要拆离的耦合点:
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
|
||||
- 直接持有 `PlondsStaticUpdateService` 与 `PlondsReleaseUpdateService`
|
||||
- 在 `CheckForUpdatesCoreAsync` 中把 PLONDS 和 GitHub Update fallback 逻辑混在一起
|
||||
- `LanMountainDesktop/Services/Update/UpdateInstallGateway.cs`
|
||||
- 直接判断 `UpdatePayloadKind.DeltaPlonds`
|
||||
- 直接实例化 `PlondsUpdateApplier`
|
||||
- `LanMountainDesktop/Services/Update/Plonds*.cs`
|
||||
- PLONDS apply/parser/payload resolver 仍位于 Update 命名空间
|
||||
|
||||
## 3. Source 发现规则
|
||||
|
||||
PLONDS 客户端内置两个初始地址:
|
||||
|
||||
1. S3 上的 PLONDS manifest 地址
|
||||
2. GitHub Release 上的 PLONDS manifest 地址
|
||||
|
||||
两个地址读取的是同一种 JSON 文件,当前文件名为 `PLONDS.json`。客户端每次检查增量更新时,会并行或顺序请求所有已知 source 的 `PLONDS.json`。
|
||||
|
||||
### 3.1 Source 扩展
|
||||
|
||||
`PLONDS.json` 可以声明额外 source。客户端读取到额外 source 后,应把它们加入下一轮寻找列表。
|
||||
|
||||
建议 manifest 扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"id": "rainyun-s3",
|
||||
"kind": "s3",
|
||||
"manifestUrl": "https://example.com/plonds/1.2.3/PLONDS.json",
|
||||
"priority": 100
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"kind": "github",
|
||||
"manifestUrl": "https://github.com/owner/repo/releases/download/v1.2.3/PLONDS.json",
|
||||
"priority": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `sources` 为空或缺失时,只使用内置 S3 + GitHub。
|
||||
- 新 source 不覆盖内置 source,除非 `id` 相同。
|
||||
- source 列表需要去重,按 `id` 和 `manifestUrl` 双重去重。
|
||||
- source 持久化到 PLONDS 自己的配置/缓存,不写入 Update 设置。
|
||||
|
||||
## 4. 版本选择规则
|
||||
|
||||
如果多个 source 返回的版本不一致,客户端选择 `currentVersion` 最高的 manifest。
|
||||
|
||||
规则:
|
||||
|
||||
- 版本解析使用 `Version` 语义,忽略前导 `v`。
|
||||
- 版本相同时,优先选择下载可用性更高的 source。
|
||||
- 如果最高版本 manifest 下载包失败,可以尝试同版本的其他 source。
|
||||
- 不因为低版本 source 成功而降级,除非用户显式允许。
|
||||
|
||||
## 5. 下载与回退规则
|
||||
|
||||
PLONDS 服务优先走增量包:
|
||||
|
||||
1. 下载所选 manifest。
|
||||
2. 下载 `changed.zip`。
|
||||
3. 校验 `changed.zip` 与 manifest 中的 hash/checksum。
|
||||
4. 解压或准备增量 staging。
|
||||
5. 交给安装程序执行增量安装。
|
||||
|
||||
如果增量流程失败,PLONDS 服务自动改用完整包:
|
||||
|
||||
1. 下载 `Files.zip`。
|
||||
2. 校验 `Files.zip`。
|
||||
3. 解压或准备完整包 staging。
|
||||
4. 交给安装程序执行完整包安装。
|
||||
|
||||
如果完整包也失败,PLONDS 服务返回失败结果,由 UI 展示错误和重试入口。
|
||||
|
||||
## 6. 发布产物布局
|
||||
|
||||
Publisher 上传到 S3 的版本目录:
|
||||
|
||||
```text
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `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 的增量包、完整包位置。
|
||||
|
||||
## 7. 建议代码结构
|
||||
|
||||
```text
|
||||
LanMountainDesktop/Services/Plonds/
|
||||
IPlondsService.cs
|
||||
PlondsService.cs
|
||||
Sources/
|
||||
IPlondsSource.cs
|
||||
PlondsHttpManifestSource.cs
|
||||
PlondsSourceRegistry.cs
|
||||
Download/
|
||||
PlondsDownloader.cs
|
||||
PlondsDownloadPlanner.cs
|
||||
Verification/
|
||||
PlondsVerifier.cs
|
||||
Staging/
|
||||
PlondsPackageStore.cs
|
||||
PlondsPreparedPackage.cs
|
||||
Models/
|
||||
PlondsClientManifest.cs
|
||||
PlondsSourceDescriptor.cs
|
||||
PlondsCheckResult.cs
|
||||
```
|
||||
|
||||
后续如果要移植,优先把这棵目录或等价项目抽成独立库。
|
||||
|
||||
## 8. 与安装程序的交接契约
|
||||
|
||||
PLONDS 服务输出本地 prepared package:
|
||||
|
||||
```csharp
|
||||
public sealed record PlondsPreparedPackage(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string ManifestPath,
|
||||
string? ChangedZipPath,
|
||||
string? ChangedDirectory,
|
||||
string? FilesZipPath,
|
||||
string? FilesDirectory);
|
||||
```
|
||||
|
||||
安装程序只接受这个结果,不参与 source 发现、下载和校验。
|
||||
|
||||
## 9. 实施顺序
|
||||
|
||||
1. Publisher 补齐完整包 S3 上传与 manifest downloads 字段。
|
||||
2. 新增 `Services/Plonds/` 客户端服务骨架和模型。
|
||||
3. 把 `PlondsStaticUpdateService` / `PlondsReleaseUpdateService` 合并迁移到独立 PLONDS source 体系。
|
||||
4. 把 `LanMountainDesktop/Services/Update/Plonds*.cs` 迁出 Update 命名空间。
|
||||
5. `UpdateSettingsService` 改为调用 `IPlondsService`,不再直接组合 S3/GitHub PLONDS fallback。
|
||||
6. 安装入口只接收 `PlondsPreparedPackage`。
|
||||
7. 添加单元测试覆盖 source 扩展、最高版本选择、增量失败转完整包、完整包失败交 UI。
|
||||
@@ -319,13 +319,18 @@ jobs:
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
→ 回写 PLONDS.json downloads 字段:
|
||||
downloads.github.releaseUrl
|
||||
downloads.github.manifestUrl
|
||||
downloads.github.changedZipUrl
|
||||
downloads.github.filesZipUrl
|
||||
downloads.s3.manifestUrl
|
||||
downloads.s3.changedZipUrl
|
||||
downloads.s3.changedFolderUrl
|
||||
downloads.s3.filesZipUrl
|
||||
downloads.s3.filesFolderUrl
|
||||
- 将回写后的 PLONDS.json 重新上传到 GitHub Release
|
||||
```
|
||||
|
||||
|
||||
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 plonds = new FakePlondsService
|
||||
{
|
||||
LatestResult = PlondsLatestResult.Available(
|
||||
new Version(1, 0, 0),
|
||||
new Version(9, 9, 9),
|
||||
[new PlondsManifestCandidate(
|
||||
new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||
CreatePlondsManifest("9.9.9"))])
|
||||
};
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS.");
|
||||
},
|
||||
plondsService: plonds);
|
||||
|
||||
var manifest = await provider.GetLatestAsync(
|
||||
UpdateSettingsValues.ChannelStable,
|
||||
"windows-x64",
|
||||
new Version(1, 0, 0),
|
||||
CancellationToken.None);
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("github", manifest?.DistributionId);
|
||||
Assert.Equal(0, plonds.GetLatestCalls);
|
||||
Assert.Equal(1, github.GetLatestCalls);
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
Assert.Equal("9.9.9", report.LatestVersion);
|
||||
Assert.Equal(1, plonds.FindLatestCalls);
|
||||
Assert.False(orchestratorCreated);
|
||||
}
|
||||
|
||||
update.State = update.State with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds };
|
||||
manifest = await provider.GetLatestAsync(
|
||||
UpdateSettingsValues.ChannelStable,
|
||||
"windows-x64",
|
||||
new Version(1, 0, 0),
|
||||
CancellationToken.None);
|
||||
[Fact]
|
||||
public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator()
|
||||
{
|
||||
var settings = new FakeSettingsService
|
||||
{
|
||||
Snapshot =
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||
}
|
||||
};
|
||||
var orchestrator = CreateTestOrchestrator(DefaultUpdateState() with
|
||||
{
|
||||
UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub
|
||||
});
|
||||
var orchestratorCreated = false;
|
||||
var service = new UpdateSettingsService(
|
||||
settings,
|
||||
orchestratorFactory: () =>
|
||||
{
|
||||
orchestratorCreated = true;
|
||||
return orchestrator;
|
||||
},
|
||||
plondsService: new FakePlondsService());
|
||||
|
||||
Assert.Equal("plonds", manifest?.DistributionId);
|
||||
Assert.Equal(1, plonds.GetLatestCalls);
|
||||
var _ = service.CurrentPhase;
|
||||
|
||||
Assert.False(orchestratorCreated);
|
||||
|
||||
var report = await service.CheckAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(orchestratorCreated);
|
||||
Assert.True(report.IsUpdateAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -177,6 +217,33 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
LastUpdateCheckUtcMs: null,
|
||||
PendingUpdateSha256: null);
|
||||
|
||||
private static UpdateOrchestrator CreateTestOrchestrator(SettingsUpdateState state)
|
||||
{
|
||||
return new UpdateOrchestrator(
|
||||
new FakeManifestProvider("github"),
|
||||
new UpdateDownloadEngine(new FakeManifestProvider("github"), new ResumableDownloadService(new HttpClient(new EmptyHandler()))),
|
||||
new UpdateInstallGateway(),
|
||||
new UpdateStateStore(new FakeSettingsFacade(new FakeUpdateSettingsService { State = state })));
|
||||
}
|
||||
|
||||
private static PlondsClientManifest CreatePlondsManifest(string version)
|
||||
{
|
||||
return new PlondsClientManifest(
|
||||
FormatVersion: "2.0",
|
||||
CurrentVersion: version,
|
||||
PreviousVersion: "1.0.0",
|
||||
IsFullUpdate: false,
|
||||
RequiresCleanInstall: false,
|
||||
Channel: "stable",
|
||||
Platform: "windows-x64",
|
||||
UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"),
|
||||
FilesMap: new Dictionary<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);
|
||||
var result = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
return new UpdateCheckResult(
|
||||
Success: result.Success,
|
||||
IsUpdateAvailable: isForce || result.IsUpdateAvailable,
|
||||
CurrentVersionText: currentVersion.ToString(),
|
||||
LatestVersionText: result.LatestVersion?.ToString() ?? "-",
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
if (staticResult.Success)
|
||||
private async Task<UpdateCheckReport> CheckPlondsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_plondsPhase.CanCheck())
|
||||
{
|
||||
return staticResult;
|
||||
return new UpdateCheckReport(false, null, null, null, null, null, null, null, null, $"Cannot check in phase {_plondsPhase}.");
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS static update check failed and will fallback to GitHub release PLONDS. Error: {staticResult.ErrorMessage}");
|
||||
|
||||
var plondsResult = isForce
|
||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (plondsResult.Success)
|
||||
TransitionPlonds(UpdatePhase.Checking);
|
||||
var currentVersionText = LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider.ResolveForCurrentProcess().Version;
|
||||
if (!TryParseVersion(currentVersionText, out var currentVersion))
|
||||
{
|
||||
return plondsResult;
|
||||
TransitionPlonds(UpdatePhase.Failed);
|
||||
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, $"Invalid current version text: {currentVersionText}");
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
||||
var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
|
||||
_pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null;
|
||||
_pendingPlondsPackage = null;
|
||||
TransitionPlonds(UpdatePhase.Checked);
|
||||
SaveLastChecked();
|
||||
|
||||
var githubFallbackResult = isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (githubFallbackResult.Success)
|
||||
if (!latest.Success)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
|
||||
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
|
||||
}
|
||||
|
||||
return githubFallbackResult;
|
||||
return new UpdateCheckReport(
|
||||
latest.IsUpdateAvailable,
|
||||
latest.LatestVersion?.ToString(),
|
||||
currentVersionText,
|
||||
latest.IsUpdateAvailable ? UpdatePayloadKind.DeltaPlonds : null,
|
||||
latest.Candidates.FirstOrDefault()?.Source.Id,
|
||||
Get().UpdateChannel,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
private async Task<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);
|
||||
|
||||
@@ -5,6 +5,7 @@ public sealed record PlondsPublishOptions(
|
||||
string Repository,
|
||||
string ManifestPath,
|
||||
string ChangedZipPath,
|
||||
string FilesZipPath,
|
||||
string WorkDir,
|
||||
string S3KeyPrefix,
|
||||
PlondsS3ClientOptions S3);
|
||||
|
||||
@@ -10,4 +10,9 @@ public sealed record PlondsPublishResult(
|
||||
string ChangedZipUrl,
|
||||
string ChangedFolderKey,
|
||||
string ChangedFolderUrl,
|
||||
int ChangedFileCount);
|
||||
string FilesZipKey,
|
||||
string FilesZipUrl,
|
||||
string FilesFolderKey,
|
||||
string FilesFolderUrl,
|
||||
int ChangedFileCount,
|
||||
int FilesFileCount);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Plonds.Shared.Models;
|
||||
@@ -21,12 +22,15 @@ public sealed class PlondsPublisher
|
||||
var repository = Require(options.Repository, nameof(options.Repository));
|
||||
var manifestPath = Path.GetFullPath(Require(options.ManifestPath, nameof(options.ManifestPath)));
|
||||
var changedZipPath = Path.GetFullPath(Require(options.ChangedZipPath, nameof(options.ChangedZipPath)));
|
||||
var filesZipPath = Path.GetFullPath(Require(options.FilesZipPath, nameof(options.FilesZipPath)));
|
||||
var workDir = Path.GetFullPath(Require(options.WorkDir, nameof(options.WorkDir)));
|
||||
var version = releaseTag.TrimStart('v', 'V');
|
||||
var prefix = NormalizePrefix(options.S3KeyPrefix);
|
||||
var versionPrefix = $"{prefix}/{version}";
|
||||
var changedFolderName = $"{version}-changed";
|
||||
var filesFolderName = $"{version}-Files";
|
||||
var changedExtractRoot = Path.Combine(workDir, changedFolderName);
|
||||
var filesExtractRoot = Path.Combine(workDir, filesFolderName);
|
||||
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
@@ -38,35 +42,48 @@ public sealed class PlondsPublisher
|
||||
throw new FileNotFoundException("PLONDS changed.zip not found.", changedZipPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(filesZipPath))
|
||||
{
|
||||
throw new FileNotFoundException("PLONDS files zip not found.", filesZipPath);
|
||||
}
|
||||
|
||||
var manifest = LoadManifest(manifestPath);
|
||||
PayloadUtilities.EnsureCleanDirectory(changedExtractRoot);
|
||||
ZipFile.ExtractToDirectory(changedZipPath, changedExtractRoot, overwriteFiles: true);
|
||||
PayloadUtilities.EnsureCleanDirectory(filesExtractRoot);
|
||||
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";
|
||||
var filesFolderKey = $"{versionPrefix}/{filesFolderName}";
|
||||
|
||||
using var s3 = new PlondsS3Client(options.S3);
|
||||
|
||||
var changedFileCount = 0;
|
||||
foreach (var filePath in Directory.EnumerateFiles(changedExtractRoot, "*", SearchOption.AllDirectories).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var relativePath = PayloadUtilities.NormalizeRelativePath(Path.GetRelativePath(changedExtractRoot, filePath));
|
||||
var objectKey = $"{changedFolderKey}/{relativePath}";
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filePath, objectKey, ResolveContentType(filePath)), cancellationToken).ConfigureAwait(false);
|
||||
changedFileCount++;
|
||||
}
|
||||
var changedFileCount = await UploadDirectoryAsync(s3, changedExtractRoot, changedFolderKey, cancellationToken).ConfigureAwait(false);
|
||||
var filesFileCount = await UploadDirectoryAsync(s3, filesExtractRoot, filesFolderKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(changedZipPath, changedZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filesZipPath, filesZipKey, "application/zip"), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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(
|
||||
ReleaseUrl: $"https://github.com/{repository}/releases/tag/{releaseTag}",
|
||||
ManifestUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/PLONDS.json",
|
||||
ChangedZipUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/changed.zip"),
|
||||
ChangedZipUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/changed.zip",
|
||||
FilesZipUrl: $"https://github.com/{repository}/releases/download/{releaseTag}/{Path.GetFileName(filesZipPath)}"),
|
||||
S3: new PlondsS3DownloadInfo(
|
||||
Bucket: options.S3.Bucket,
|
||||
Prefix: versionPrefix,
|
||||
@@ -75,14 +92,21 @@ public sealed class PlondsPublisher
|
||||
ChangedZipKey: changedZipKey,
|
||||
ChangedZipUrl: s3.BuildPublicUrl(changedZipKey),
|
||||
ChangedFolderKey: changedFolderKey,
|
||||
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey)))
|
||||
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey),
|
||||
FilesZipKey: filesZipKey,
|
||||
FilesZipUrl: s3.BuildPublicUrl(filesZipKey),
|
||||
FilesFolderKey: filesFolderKey,
|
||||
FilesFolderUrl: s3.BuildPublicUrl(filesFolderKey)))
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
return new PlondsPublishResult(
|
||||
ReleaseTag: releaseTag,
|
||||
@@ -94,7 +118,30 @@ public sealed class PlondsPublisher
|
||||
ChangedZipUrl: s3.BuildPublicUrl(changedZipKey),
|
||||
ChangedFolderKey: changedFolderKey,
|
||||
ChangedFolderUrl: s3.BuildPublicUrl(changedFolderKey),
|
||||
ChangedFileCount: changedFileCount);
|
||||
FilesZipKey: filesZipKey,
|
||||
FilesZipUrl: s3.BuildPublicUrl(filesZipKey),
|
||||
FilesFolderKey: filesFolderKey,
|
||||
FilesFolderUrl: s3.BuildPublicUrl(filesFolderKey),
|
||||
ChangedFileCount: changedFileCount,
|
||||
FilesFileCount: filesFileCount);
|
||||
}
|
||||
|
||||
private static async Task<int> UploadDirectoryAsync(
|
||||
PlondsS3Client s3,
|
||||
string sourceDirectory,
|
||||
string destinationKeyPrefix,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var filePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories).OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var relativePath = PayloadUtilities.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, filePath));
|
||||
var objectKey = $"{destinationKeyPrefix}/{relativePath}";
|
||||
await s3.UploadFileAsync(new PlondsS3ObjectUpload(filePath, objectKey, ResolveContentType(filePath)), cancellationToken).ConfigureAwait(false);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static PlondsManifest LoadManifest(string manifestPath)
|
||||
@@ -104,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('/');
|
||||
|
||||
@@ -11,7 +11,8 @@ public sealed record PlondsDownloadInfo(
|
||||
public sealed record PlondsGitHubDownloadInfo(
|
||||
string ReleaseUrl,
|
||||
string ManifestUrl,
|
||||
string ChangedZipUrl);
|
||||
string ChangedZipUrl,
|
||||
string FilesZipUrl);
|
||||
|
||||
public sealed record PlondsS3DownloadInfo(
|
||||
string Bucket,
|
||||
@@ -21,4 +22,8 @@ public sealed record PlondsS3DownloadInfo(
|
||||
string ChangedZipKey,
|
||||
string ChangedZipUrl,
|
||||
string ChangedFolderKey,
|
||||
string ChangedFolderUrl);
|
||||
string ChangedFolderUrl,
|
||||
string FilesZipKey,
|
||||
string FilesZipUrl,
|
||||
string FilesFolderKey,
|
||||
string FilesFolderUrl);
|
||||
|
||||
@@ -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);
|
||||
@@ -103,6 +103,7 @@ internal static class PlondsCli
|
||||
Repository: Require(options, "repository"),
|
||||
ManifestPath: Require(options, "manifest"),
|
||||
ChangedZipPath: Require(options, "changed-zip"),
|
||||
FilesZipPath: Require(options, "files-zip"),
|
||||
WorkDir: Get(options, "work-dir", "plonds-publish-work") ?? "plonds-publish-work",
|
||||
S3KeyPrefix: Get(options, "s3-prefix", "lanmountain/update/plonds") ?? "lanmountain/update/plonds",
|
||||
S3: new PlondsS3ClientOptions(
|
||||
@@ -119,7 +120,10 @@ internal static class PlondsCli
|
||||
Console.WriteLine($" Manifest: {result.ManifestUrl}");
|
||||
Console.WriteLine($" ChangedZip: {result.ChangedZipUrl}");
|
||||
Console.WriteLine($" ChangedFolder: {result.ChangedFolderUrl}");
|
||||
Console.WriteLine($" FilesZip: {result.FilesZipUrl}");
|
||||
Console.WriteLine($" FilesFolder: {result.FilesFolderUrl}");
|
||||
Console.WriteLine($" ChangedFileCount: {result.ChangedFileCount}");
|
||||
Console.WriteLine($" FilesFileCount: {result.FilesFileCount}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -199,6 +203,7 @@ internal static class PlondsCli
|
||||
Console.WriteLine(" --repository <owner/repo> GitHub repository");
|
||||
Console.WriteLine(" --manifest <file> PLONDS.json path");
|
||||
Console.WriteLine(" --changed-zip <file> changed.zip path");
|
||||
Console.WriteLine(" --files-zip <file> Full files zip path");
|
||||
Console.WriteLine(" --s3-endpoint <url> S3-compatible endpoint");
|
||||
Console.WriteLine(" --s3-region <region> S3 signing region");
|
||||
Console.WriteLine(" --s3-bucket <bucket> S3 bucket");
|
||||
|
||||
Reference in New Issue
Block a user