ci.plonds

This commit is contained in:
lincube
2026-04-21 16:12:47 +08:00
parent d31aa90b9c
commit 8568fdf16b
12 changed files with 1662 additions and 437 deletions

View File

@@ -1,4 +1,4 @@
name: Release
name: Release
on:
push:
@@ -15,6 +15,23 @@ on:
required: false
type: boolean
default: false
incremental_strategy:
description: 'Incremental strategy'
required: false
type: choice
default: release-payload
options:
- release-payload
- commit-range
publish_incremental_release:
description: 'Publish as incremental release'
required: false
type: boolean
default: true
baseline_ref:
description: 'Optional baseline tag/version/commit'
required: false
type: string
env:
DOTNET_VERSION: '10.0.x'
@@ -32,6 +49,11 @@ jobs:
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
steps:
- name: Checkout repository metadata
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release info
id: version
run: |
@@ -47,7 +69,11 @@ jobs:
else
TAG="v${RAW_TAG}"
fi
CHECKOUT_REF="${GITHUB_SHA}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
CHECKOUT_REF="refs/tags/${TAG}"
else
CHECKOUT_REF="${GITHUB_SHA}"
fi
fi
VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
@@ -120,14 +146,18 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-p:DebugType=none `
-p:DebugSymbols=false
-p:DebugSymbols=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if ($LASTEXITCODE -ne 0) {
Write-Error "Launcher AOT publish failed"
exit 1
}
# 閺勫墽銇氶崣鎴濈缂佹挻鐏?
# é<EFBFBD>„剧ã<EFBFBD>šé<EFBFBD>™æˆ<EFBFBD>ç«·ç¼<EFBFBD>æ´ç<EFBFBD>?
Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
@@ -384,7 +414,7 @@ jobs:
- name: Publish Launcher (AOT)
run: |
echo "Publishing Launcher with AOT for Linux x64..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-linux-x64 \
@@ -395,13 +425,17 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed"
exit 1
fi
echo "Launcher published to: ./publish/launcher-linux-x64"
ls -lh ./publish/launcher-linux-x64/
@@ -587,7 +621,7 @@ jobs:
- name: Publish Launcher (AOT)
run: |
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \
@@ -598,13 +632,17 @@ jobs:
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if [ $? -ne 0 ]; then
echo "Launcher AOT publish failed"
exit 1
fi
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
@@ -737,6 +775,10 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -816,6 +858,21 @@ jobs:
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$incrementalStrategy = if ("${{ github.event_name }}" -eq "workflow_dispatch" -and -not [string]::IsNullOrWhiteSpace("${{ github.event.inputs.incremental_strategy }}")) {
"${{ github.event.inputs.incremental_strategy }}"
} else {
"release-payload"
}
$publishIncrementalRelease = if ("${{ github.event_name }}" -eq "workflow_dispatch" -and -not [string]::IsNullOrWhiteSpace("${{ github.event.inputs.publish_incremental_release }}")) {
"${{ github.event.inputs.publish_incremental_release }}"
} else {
"true"
}
$baselineRef = if ("${{ github.event_name }}" -eq "workflow_dispatch") {
"${{ github.event.inputs.baseline_ref }}"
} else {
""
}
./scripts/Publish-Plonds.ps1 `
-Version $env:VERSION `
@@ -826,7 +883,14 @@ jobs:
-Channel "stable" `
-S3Endpoint $env:S3_ENDPOINT `
-S3Bucket $env:S3_BUCKET `
-S3Region $env:S3_REGION
-S3Region $env:S3_REGION `
-IncrementalStrategy $incrementalStrategy `
-PublishIncrementalRelease $publishIncrementalRelease `
-BaselineRef $baselineRef `
-GitHubRepository "${{ github.repository }}" `
-GitHubTag "${{ needs.prepare.outputs.tag }}" `
-MirrorInstallersToS3 "false" `
-UploadMetaToS3 "false"
- name: Upload PLONDS assets
uses: actions/upload-artifact@v4
@@ -872,7 +936,7 @@ jobs:
echo "Organizing artifacts..."
mkdir -p release-files
find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
find artifacts/plonds -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" -o -name "plonds-*.json" -o -name "plonds-*.json.sig" \) -exec cp -v {} release-files/ \;
find artifacts/plonds -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" -o -name "plonds-*.json" -o -name "plonds-*.json.sig" -o -name "plonds-payload-*.zip" \) -exec cp -v {} release-files/ \;
echo ""
echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files"
@@ -906,7 +970,15 @@ jobs:
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update Assets`n - **plonds-filemap-windows-x64.json / plonds-filemap-windows-x64.json.sig**`n - **plonds-filemap-windows-x86.json / plonds-filemap-windows-x86.json.sig**`n - **plonds-filemap-linux-x64.json / plonds-filemap-linux-x64.json.sig**`n`n ### Legacy Fallback Assets
### Incremental Update Assets
- **plonds-filemap-windows-x64.json / plonds-filemap-windows-x64.json.sig**
- **plonds-filemap-windows-x86.json / plonds-filemap-windows-x86.json.sig**
- **plonds-filemap-linux-x64.json / plonds-filemap-linux-x64.json.sig**
- **plonds-payload-windows-x64.zip**
- **plonds-payload-windows-x86.zip**
- **plonds-payload-linux-x64.zip**
### Legacy Fallback Assets
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
@@ -923,3 +995,42 @@ jobs:
See commits for changes.
token: ${{ secrets.GITHUB_TOKEN }}
publish-plonds-meta:
needs: [ prepare, publish-plonds, github-release ]
runs-on: ubuntu-latest
permissions:
contents: read
env:
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
steps:
- name: Download PLONDS artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/plonds
pattern: plonds-assets
- name: Publish PLONDS meta to S3
if: ${{ env.S3_ENDPOINT != '' && env.S3_BUCKET != '' && env.S3_ACCESS_KEY != '' && env.S3_SECRET_KEY != '' }}
shell: bash
run: |
set -euo pipefail
meta_dir="$(find artifacts/plonds -type d -path '*/published/meta' | head -n 1)"
if [ -z "${meta_dir}" ]; then
echo "Unable to locate published/meta inside PLONDS artifacts"
exit 1
fi
echo "Publishing PLONDS meta from ${meta_dir}"
aws --endpoint-url "$S3_ENDPOINT" --region "$S3_REGION" s3 cp "$meta_dir" "s3://$S3_BUCKET/lanmountain/update/meta/" --recursive --only-show-errors --no-progress

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
@@ -17,6 +18,7 @@ namespace LanMountainDesktop.Services;
public sealed class PlondsReleaseUpdateService : IDisposable
{
private const string DefaultApiBasePath = "/api/plonds/v1";
private const int MaxTransientRetryAttempts = 3;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
@@ -71,6 +73,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
var endpoint = ResolveEndpoint();
var latestVersionText = "-";
if (string.IsNullOrWhiteSpace(endpoint))
{
@@ -78,7 +81,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS endpoint is not configured.",
@@ -89,8 +92,6 @@ public sealed class PlondsReleaseUpdateService : IDisposable
{
var apiBasePath = ResolveApiBasePath();
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
var metadata = await GetJsonNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var channelId = ResolveChannelId(includePrerelease);
var platform = ResolvePlatform();
var latestUrl = BuildApiUrl(
@@ -98,12 +99,14 @@ public sealed class PlondsReleaseUpdateService : IDisposable
apiBasePath,
$"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
JsonElement latestNode;
try
{
latestNode = await GetJsonNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message.StartsWith("HTTP 204", StringComparison.OrdinalIgnoreCase))
_ = await GetJsonNodeWithRetryAsync(metadataUrl, PlondsCheckStage.Metadata, cancellationToken).ConfigureAwait(false);
var latestDescriptor = await GetLatestDescriptorAsync(
latestUrl,
allowNoUpdateResponse: true,
cancellationToken).ConfigureAwait(false);
if (latestDescriptor is null)
{
return new UpdateCheckResult(
Success: true,
@@ -116,35 +119,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable
ForceMode: isForce);
}
var latestVersionText = ReadString(latestNode, "version") ?? "-";
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS latest distribution version is invalid.",
ForceMode: isForce);
}
var distributionId = ReadString(latestNode, "distributionId");
if (string.IsNullOrWhiteSpace(distributionId))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS latest distribution id is missing.",
ForceMode: isForce);
}
var hasUpdate = latestVersion > normalizedCurrentVersion;
latestVersionText = latestDescriptor.VersionText;
var hasUpdate = latestDescriptor.Version > normalizedCurrentVersion;
if (!isForce && !hasUpdate)
{
return new UpdateCheckResult(
@@ -158,58 +134,67 @@ public sealed class PlondsReleaseUpdateService : IDisposable
ForceMode: false);
}
var distributionUrl = BuildApiUrl(
var distribution = await ResolveDistributionAsync(
endpoint,
apiBasePath,
$"distributions/{Uri.EscapeDataString(distributionId)}");
var distributionNode = await GetJsonNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
latestUrl,
latestDescriptor,
channelId,
platform,
cancellationToken).ConfigureAwait(false);
var assets = ResolveInstallerAssets(distributionNode);
var payload = ResolvePlondsPayload(distributionNode, distributionId, channelId, platform);
if (assets.Count == 0 && !HasPlondsPayload(payload))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PLONDS distribution response does not expose downloadable update assets.",
ForceMode: isForce);
}
latestVersionText = distribution.Latest.VersionText;
var publishedAt = ParsePublishedAt(distributionNode) ?? DateTimeOffset.UtcNow;
var publishedAt = ParsePublishedAt(distribution.DistributionNode) ?? DateTimeOffset.UtcNow;
var release = new GitHubReleaseInfo(
TagName: $"v{latestVersionText}",
Name: $"PLONDS Distribution {latestVersionText}",
TagName: $"v{distribution.Latest.VersionText}",
Name: $"PLONDS Distribution {distribution.Latest.VersionText}",
IsPrerelease: includePrerelease,
IsDraft: false,
PublishedAt: publishedAt,
Assets: assets);
Assets: distribution.Assets);
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: true,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
LatestVersionText: distribution.Latest.VersionText,
Release: release,
PreferredAsset: SelectPreferredInstallerAsset(assets),
PreferredAsset: SelectPreferredInstallerAsset(distribution.Assets),
ErrorMessage: null,
ForceMode: isForce,
PlondsPayload: payload);
PlondsPayload: distribution.Payload);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
catch (PlondsRequestException ex)
{
AppLogger.Warn(
"PLONDS",
$"PLONDS {GetStageName(ex.Stage)} stage failed. {ex.Message}",
ex);
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: $"PLONDS {GetStageName(ex.Stage)} failed: {ex.Message}",
ForceMode: isForce);
}
catch (Exception ex)
{
AppLogger.Warn("PLONDS", "PLONDS request failed with an unexpected error.", ex);
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: $"PLONDS request failed: {ex.Message}",
@@ -217,7 +202,125 @@ public sealed class PlondsReleaseUpdateService : IDisposable
}
}
private async Task<JsonElement> GetJsonNodeAsync(string url, CancellationToken cancellationToken)
private async Task<LatestDescriptor?> GetLatestDescriptorAsync(
string latestUrl,
bool allowNoUpdateResponse,
CancellationToken cancellationToken)
{
try
{
var latestNode = await GetJsonNodeWithRetryAsync(
latestUrl,
PlondsCheckStage.Latest,
cancellationToken).ConfigureAwait(false);
return ParseLatestDescriptor(latestNode);
}
catch (PlondsRequestException ex)
when (allowNoUpdateResponse &&
ex.Stage == PlondsCheckStage.Latest &&
ex.StatusCode == HttpStatusCode.NoContent)
{
return null;
}
}
private async Task<DistributionDescriptor> ResolveDistributionAsync(
string endpoint,
string apiBasePath,
string latestUrl,
LatestDescriptor latest,
string channelId,
string platform,
CancellationToken cancellationToken)
{
var currentLatest = latest;
var hasRefreshedLatest = false;
while (true)
{
var distributionUrl = BuildApiUrl(
endpoint,
apiBasePath,
$"distributions/{Uri.EscapeDataString(currentLatest.DistributionId)}");
try
{
var distributionNode = await GetJsonNodeWithRetryAsync(
distributionUrl,
PlondsCheckStage.Distribution,
cancellationToken).ConfigureAwait(false);
if (TryCreateDistributionDescriptor(
distributionNode,
currentLatest,
channelId,
platform,
out var descriptor,
out var descriptorError))
{
return descriptor;
}
if (hasRefreshedLatest || descriptorError is null || !IsRecoverableDistributionError(descriptorError))
{
throw descriptorError ?? new PlondsRequestException(
PlondsCheckStage.PayloadParse,
"PLONDS distribution payload is incomplete.");
}
AppLogger.Warn(
"PLONDS",
$"PLONDS distribution '{currentLatest.DistributionId}' is incomplete. Refreshing latest pointer once before failing.");
}
catch (PlondsRequestException ex) when (!hasRefreshedLatest && IsRecoverableDistributionError(ex))
{
AppLogger.Warn(
"PLONDS",
$"PLONDS distribution fetch for '{currentLatest.DistributionId}' failed during {GetStageName(ex.Stage)}. Refreshing latest pointer once. Details: {ex.Message}");
}
hasRefreshedLatest = true;
currentLatest = await GetLatestDescriptorAsync(
latestUrl,
allowNoUpdateResponse: false,
cancellationToken).ConfigureAwait(false)
?? throw new PlondsRequestException(
PlondsCheckStage.Latest,
"PLONDS latest pointer disappeared while recovering the distribution payload.");
}
}
private async Task<JsonElement> GetJsonNodeWithRetryAsync(
string url,
PlondsCheckStage stage,
CancellationToken cancellationToken)
{
PlondsRequestException? lastError = null;
for (var attempt = 1; attempt <= MaxTransientRetryAttempts; attempt++)
{
try
{
return await GetJsonNodeAsync(url, stage, cancellationToken).ConfigureAwait(false);
}
catch (PlondsRequestException ex) when (attempt < MaxTransientRetryAttempts && ex.IsTransient)
{
lastError = ex;
AppLogger.Warn(
"PLONDS",
$"PLONDS {GetStageName(stage)} attempt {attempt}/{MaxTransientRetryAttempts} failed. Retrying shortly. Details: {ex.Message}");
await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false);
}
}
throw lastError ?? new PlondsRequestException(stage, "PLONDS request failed before a response was returned.");
}
private async Task<JsonElement> GetJsonNodeAsync(
string url,
PlondsCheckStage stage,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var token = ResolveToken();
@@ -226,22 +329,127 @@ public sealed class PlondsReleaseUpdateService : IDisposable
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
HttpResponseMessage response;
try
{
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}");
response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new PlondsRequestException(stage, "Request timed out.", isTransient: true, innerException: ex);
}
catch (HttpRequestException ex)
{
throw new PlondsRequestException(stage, $"Network error: {ex.Message}", isTransient: true, innerException: ex);
}
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("content", out var content))
using (response)
{
return content.Clone();
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NoContent)
{
throw new PlondsRequestException(
stage,
"HTTP 204: no content.",
statusCode: response.StatusCode,
isTransient: false);
}
if (!response.IsSuccessStatusCode)
{
throw new PlondsRequestException(
stage,
$"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}",
statusCode: response.StatusCode,
isTransient: IsTransientStatusCode(response.StatusCode));
}
try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("content", out var content))
{
return content.Clone();
}
return root.Clone();
}
catch (JsonException ex)
{
throw new PlondsRequestException(
stage,
$"Invalid JSON response: {ex.Message}",
isTransient: IsLikelyIncompleteJson(body),
innerException: ex);
}
}
}
private static LatestDescriptor ParseLatestDescriptor(JsonElement latestNode)
{
var latestVersionText = ReadString(latestNode, "version") ?? "-";
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
{
throw new PlondsRequestException(
PlondsCheckStage.Latest,
$"PLONDS latest distribution version is invalid: '{latestVersionText}'.");
}
return root.Clone();
var distributionId = ReadString(latestNode, "distributionId");
if (string.IsNullOrWhiteSpace(distributionId))
{
throw new PlondsRequestException(
PlondsCheckStage.Latest,
"PLONDS latest distribution id is missing.");
}
return new LatestDescriptor(distributionId, latestVersionText, latestVersion);
}
private static bool TryCreateDistributionDescriptor(
JsonElement distributionNode,
LatestDescriptor latest,
string channelId,
string platform,
out DistributionDescriptor descriptor,
out PlondsRequestException? error)
{
descriptor = default!;
error = null;
var assets = ResolveInstallerAssets(distributionNode);
var payload = ResolvePlondsPayload(
distributionNode,
latest.DistributionId,
channelId,
platform);
if (assets.Count == 0 && !HasPlondsPayload(payload))
{
error = new PlondsRequestException(
PlondsCheckStage.PayloadParse,
"PLONDS distribution response does not expose downloadable update assets.");
return false;
}
descriptor = new DistributionDescriptor(latest, distributionNode, assets, payload);
return true;
}
private static bool IsRecoverableDistributionError(PlondsRequestException error)
{
if (error.Stage == PlondsCheckStage.PayloadParse)
{
return true;
}
return error.Stage == PlondsCheckStage.Distribution &&
(error.StatusCode == HttpStatusCode.NotFound ||
error.StatusCode == HttpStatusCode.RequestTimeout ||
error.StatusCode == HttpStatusCode.TooManyRequests ||
error.StatusCode is >= HttpStatusCode.InternalServerError);
}
private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode)
@@ -258,7 +466,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable
continue;
}
var name = ReadString(installerNode, "name");
var name = ReadString(installerNode, "name")
?? ReadString(installerNode, "fileName");
var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
@@ -593,4 +802,91 @@ public sealed class PlondsReleaseUpdateService : IDisposable
return value[..maxLength];
}
private static bool IsTransientStatusCode(HttpStatusCode statusCode)
{
return statusCode == HttpStatusCode.RequestTimeout ||
statusCode == HttpStatusCode.TooManyRequests ||
statusCode >= HttpStatusCode.InternalServerError;
}
private static bool IsLikelyIncompleteJson(string? body)
{
if (string.IsNullOrWhiteSpace(body))
{
return true;
}
var trimmed = body.TrimEnd();
if (trimmed.Length == 0)
{
return true;
}
var last = trimmed[^1];
return last != '}' && last != ']';
}
private static TimeSpan GetRetryDelay(int attempt)
{
return attempt switch
{
1 => TimeSpan.FromMilliseconds(350),
2 => TimeSpan.FromMilliseconds(900),
_ => TimeSpan.FromMilliseconds(1500)
};
}
private static string GetStageName(PlondsCheckStage stage)
{
return stage switch
{
PlondsCheckStage.Metadata => "metadata",
PlondsCheckStage.Latest => "latest",
PlondsCheckStage.Distribution => "distribution",
PlondsCheckStage.PayloadParse => "payload-parse",
_ => "unknown"
};
}
private enum PlondsCheckStage
{
Metadata,
Latest,
Distribution,
PayloadParse
}
private sealed record LatestDescriptor(
string DistributionId,
string VersionText,
Version Version);
private sealed record DistributionDescriptor(
LatestDescriptor Latest,
JsonElement DistributionNode,
IReadOnlyList<GitHubReleaseAsset> Assets,
PlondsUpdatePayload Payload);
private sealed class PlondsRequestException : Exception
{
public PlondsRequestException(
PlondsCheckStage stage,
string message,
HttpStatusCode? statusCode = null,
bool isTransient = false,
Exception? innerException = null)
: base(message, innerException)
{
Stage = stage;
StatusCode = statusCode;
IsTransient = isTransient;
}
public PlondsCheckStage Stage { get; }
public HttpStatusCode? StatusCode { get; }
public bool IsTransient { get; }
}
}

View File

@@ -915,6 +915,25 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
AppLogger.Warn(
"UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
{
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 githubFallbackResult;
}
return isForce

View File

@@ -71,6 +71,7 @@ public sealed class UpdateWorkflowService
};
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
private const int MaxPlondsOuterRetryAttempts = 3;
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{
@@ -251,7 +252,12 @@ public sealed class UpdateWorkflowService
var payload = checkResult.PlondsPayload;
if (payload is null)
{
return new UpdateDownloadResult(false, null, "PLONDS payload is missing.");
return await HandlePlondsDeltaFailureAsync(
checkResult,
"payload-parse",
"PLONDS payload is missing.",
progress,
cancellationToken);
}
var incomingDir = GetLauncherIncomingDirectory();
@@ -264,7 +270,12 @@ public sealed class UpdateWorkflowService
}
catch (Exception ex)
{
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
return await HandlePlondsDeltaFailureAsync(
checkResult,
"payload-parse",
$"Failed to create incoming directory: {ex.Message}",
progress,
cancellationToken);
}
try
@@ -279,18 +290,31 @@ public sealed class UpdateWorkflowService
payload.FileMapJson,
payload.FileMapJsonUrl,
fileMapPath,
"file map",
"filemap-download",
cancellationToken);
var fileMapSignature = await EnsurePlondsTextResourceAsync(
payload.FileMapSignature,
payload.FileMapSignatureUrl,
signaturePath,
"file map signature",
"filemap-download",
cancellationToken);
var downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
IReadOnlyList<PlondsDownloadEntry> downloadEntries;
try
{
downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
}
catch (JsonException ex)
{
throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex);
}
if (downloadEntries.Count == 0)
{
return new UpdateDownloadResult(false, null, "PLONDS file map does not contain downloadable objects.");
throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
}
var expectedObjectCount = downloadEntries.Count;
@@ -310,46 +334,13 @@ public sealed class UpdateWorkflowService
continue;
}
var destinationPath = GetPlondsObjectDestinationPath(objectsDir, entry.ObjectHashHex);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
if (File.Exists(destinationPath))
{
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++;
progress?.Report((double)completedItems / totalSteps);
continue;
}
}
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
var downloadResult = await PlondsDownloadService.DownloadAsync(
entry.DownloadUrl,
destinationPath,
downloadOptions,
null,
var objectInfo = await EnsurePlondsObjectAsync(
entry,
objectsDir,
downloadThreads,
cancellationToken);
if (!downloadResult.Success)
{
return new UpdateDownloadResult(false, null, $"Failed to download PLONDS object {entry.RelativePath}: {downloadResult.ErrorMessage}");
}
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(actualHash) &&
!string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new UpdateDownloadResult(false, null, $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
}
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
objectResults.Add(objectInfo);
completedItems++;
progress?.Report((double)completedItems / totalSteps);
}
@@ -390,8 +381,20 @@ public sealed class UpdateWorkflowService
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", "Failed to download PLONDS incremental payload.", ex);
return new UpdateDownloadResult(false, null, ex.Message);
var stage = ex is PlondsDownloadException plondsException
? plondsException.Stage
: "payload-parse";
var message = ex is PlondsDownloadException
? ex.Message
: $"PLONDS incremental payload failed unexpectedly: {ex.Message}";
AppLogger.Warn("UpdateWorkflow", $"Failed to download PLONDS incremental payload at stage '{stage}'.", ex);
return await HandlePlondsDeltaFailureAsync(
checkResult,
stage,
message,
progress,
cancellationToken);
}
}
@@ -420,6 +423,125 @@ public sealed class UpdateWorkflowService
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
}
private async Task<UpdateDownloadResult> DownloadFullInstallerAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress,
CancellationToken cancellationToken,
bool forceRedownload)
{
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (!forceRedownload &&
existingPending is not null &&
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath))
{
var verifyResult = await VerifyPendingUpdateAsync();
if (verifyResult.Success)
{
return new UpdateDownloadResult(
true,
existingPending.InstallerPath,
null,
verifyResult.HashMatched,
verifyResult.ExpectedHash,
verifyResult.ActualHash);
}
AppLogger.Warn(
"UpdateWorkflow",
$"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
}
if (forceRedownload && existingPending is not null && File.Exists(existingPending.InstallerPath))
{
try
{
File.Delete(existingPending.InstallerPath);
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
}
ClearPendingUpdate();
state = _settingsFacade.Update.Get();
}
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress,
cancellationToken);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
}
private async Task<UpdateDownloadResult> HandlePlondsDeltaFailureAsync(
UpdateCheckResult checkResult,
string stage,
string errorMessage,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var normalizedMessage = string.IsNullOrWhiteSpace(errorMessage)
? $"PLONDS {stage} failed."
: $"PLONDS {stage} failed: {errorMessage}";
if (checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, normalizedMessage);
}
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS delta download failed at stage '{stage}'. Falling back to full installer download. Details: {errorMessage}");
var fallbackResult = await DownloadFullInstallerAsync(
checkResult,
progress,
cancellationToken,
forceRedownload: false);
if (fallbackResult.Success)
{
return fallbackResult;
}
var combinedMessage = string.IsNullOrWhiteSpace(fallbackResult.ErrorMessage)
? normalizedMessage
: $"{normalizedMessage} Full installer fallback failed: {fallbackResult.ErrorMessage}";
return new UpdateDownloadResult(false, null, combinedMessage);
}
private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex)
{
var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
@@ -431,6 +553,8 @@ public sealed class UpdateWorkflowService
string? inlineContent,
string? sourceUrl,
string destinationPath,
string resourceName,
string stage,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(inlineContent))
@@ -441,20 +565,131 @@ public sealed class UpdateWorkflowService
if (string.IsNullOrWhiteSpace(sourceUrl))
{
throw new InvalidOperationException("PLONDS payload does not contain a file map source.");
throw new PlondsDownloadException(stage, $"PLONDS payload does not contain a {resourceName} source.");
}
var downloadResult = await PlondsDownloadService.DownloadAsync(
sourceUrl,
destinationPath,
cancellationToken: cancellationToken);
if (!downloadResult.Success)
Exception? lastError = null;
for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
{
throw new InvalidOperationException($"Failed to download PLONDS file map resource: {downloadResult.ErrorMessage}");
var downloadResult = await PlondsDownloadService.DownloadAsync(
sourceUrl,
destinationPath,
cancellationToken: cancellationToken);
if (downloadResult.Success)
{
try
{
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
}
catch (Exception ex) when (attempt < MaxPlondsOuterRetryAttempts)
{
lastError = ex;
}
}
else
{
lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS {resourceName}.");
}
if (attempt < MaxPlondsOuterRetryAttempts)
{
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS {resourceName} download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed. Retrying same URL.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
}
}
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
throw new PlondsDownloadException(
stage,
$"Failed to download PLONDS {resourceName} from {sourceUrl}.",
lastError);
}
private static async Task<PlondsDownloadedObjectInfo> EnsurePlondsObjectAsync(
PlondsDownloadEntry entry,
string objectsDirectory,
int downloadThreads,
CancellationToken cancellationToken)
{
var destinationPath = GetPlondsObjectDestinationPath(objectsDirectory, entry.ObjectHashHex);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
}
if (!string.IsNullOrWhiteSpace(existingHash))
{
DeleteFileIfExists(destinationPath);
}
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
var allowForcedRedownload = true;
Exception? lastError = null;
for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
{
var downloadResult = await PlondsDownloadService.DownloadAsync(
entry.DownloadUrl,
destinationPath,
downloadOptions,
null,
cancellationToken);
if (!downloadResult.Success)
{
lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS object {entry.RelativePath}.");
if (attempt < MaxPlondsOuterRetryAttempts)
{
AppLogger.Warn(
"UpdateWorkflow",
$"PLONDS object download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed for {entry.RelativePath}. Retrying.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
continue;
}
throw new PlondsDownloadException(
"object-download",
$"Failed to download PLONDS object {entry.RelativePath}.",
lastError);
}
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(actualHash) &&
string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
}
DeleteFileIfExists(destinationPath);
var mismatchMessage = $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash ?? "<missing>"}";
lastError = new InvalidOperationException(mismatchMessage);
if (allowForcedRedownload)
{
allowForcedRedownload = false;
AppLogger.Warn(
"UpdateWorkflow",
$"{mismatchMessage}. Removing the bad object and forcing one clean re-download.");
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
continue;
}
throw new PlondsDownloadException("object-verify", mismatchMessage, lastError);
}
throw new PlondsDownloadException(
"object-download",
$"Failed to download PLONDS object {entry.RelativePath}.",
lastError);
}
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
@@ -628,6 +863,31 @@ public sealed class UpdateWorkflowService
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
}
private static void DeleteFileIfExists(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Best effort cleanup only. The caller still verifies the resulting payload before it is applied.
}
}
private static TimeSpan GetPlondsRetryDelay(int attempt)
{
return attempt switch
{
1 => TimeSpan.FromMilliseconds(350),
2 => TimeSpan.FromMilliseconds(900),
_ => TimeSpan.FromMilliseconds(1500)
};
}
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{
if (node.ValueKind == JsonValueKind.Object)
@@ -742,6 +1002,17 @@ public sealed class UpdateWorkflowService
string DownloadUrl,
string ObjectHashHex);
private sealed class PlondsDownloadException : Exception
{
public PlondsDownloadException(string stage, string message, Exception? innerException = null)
: base(message, innerException)
{
Stage = stage;
}
public string Stage { get; }
}
private sealed record PlondsDownloadedObjectInfo(
string ComponentId,
string RelativePath,
@@ -876,53 +1147,11 @@ public sealed class UpdateWorkflowService
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
}
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (existingPending is not null &&
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath))
{
var verifyResult = await VerifyPendingUpdateAsync();
if (verifyResult.Success)
{
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
}
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
}
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
return await DownloadFullInstallerAsync(
checkResult,
progress,
cancellationToken);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
cancellationToken,
forceRedownload: false);
}
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
@@ -938,58 +1167,11 @@ public sealed class UpdateWorkflowService
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
}
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
{
try
{
File.Delete(existingPending.InstallerPath);
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
}
}
ClearPendingUpdate();
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
state = _settingsFacade.Update.Get();
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
return await DownloadFullInstallerAsync(
checkResult,
progress,
cancellationToken);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
? publishedAt.ToUnixTimeMilliseconds()
: null,
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
cancellationToken,
forceRedownload: true);
}
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()

View File

@@ -1965,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
return;
}
if (result.PreferredAsset is null)
if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{
UpdateStatus = isForce
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
@@ -2050,7 +2050,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
private async Task RedownloadUpdateAsync()
{
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
if (_lastCheckResult is null ||
!_lastCheckResult.Success ||
!_lastCheckResult.IsUpdateAvailable ||
(_lastCheckResult.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(_lastCheckResult)))
{
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
return;
@@ -2233,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateDownloadResult downloadResult;
// Prefer delta update if available (smaller download, faster)
if (result.Release is not null && UpdateWorkflowService.IsDeltaUpdateAvailable(result.Release))
if (UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
if (!downloadResult.Success)
if (!downloadResult.Success && result.PlondsPayload is null)
{
// Delta download failed, fall back to full installer
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");

View File

@@ -1,10 +1,10 @@
# PLONDS Skeleton
# PLONDS 骨架
Penguin Logistics Online Network Distribution System, or PLONDS, is the standalone update-distribution skeleton for LanMountainDesktop.
Penguin Logistics Online Network Distribution System(企鹅物流在线网络分发系统),简称 PLONDS LanMountainDesktop 的独立更新分发骨架。
This directory is intentionally isolated from the main app and Launcher. It contains only the new distribution protocol, a thin read-only API, and sample S3-style metadata files.
本目录有意与主应用和启动器隔离,仅包含新的分发协议、一个轻量级的只读 API以及示例 S3 风格的元数据文件。
## Directory Layout
## 目录结构
```text
PenguinLogisticsOnlineNetworkDistributionSystem/
@@ -22,72 +22,72 @@ PenguinLogisticsOnlineNetworkDistributionSystem/
distributions/
```
## Projects
## 项目说明
- `Plonds.Shared` provides protocol constants and models.
- `Plonds.Core` owns hashing, diffing, object-repo generation, manifest generation, signing, and publish orchestration.
- `Plonds.Tool` is the CI-facing CLI entrypoint. PowerShell should stay as a thin wrapper around this tool.
- `Plonds.Api` is a thin read-only API that reads metadata from a local folder laid out like S3.
- `Plonds.Shared` 提供协议常量和数据模型。
- `Plonds.Core` 负责哈希计算、差异生成、对象仓库生成、清单生成、签名和发布编排。
- `Plonds.Tool` 是面向 CI 的命令行入口。PowerShell 脚本应保持为围绕此工具的薄包装层。
- `Plonds.Api` 是一个轻量级只读 API从类似 S3 布局的本地文件夹中读取元数据。
## Architecture
## 架构设计
PLONDS is intentionally built around a single C# implementation stack so the protocol and publish behavior do not drift across languages.
PLONDS 有意围绕单一的 C# 实现栈构建,以确保协议和发布行为不会在不同语言之间产生偏差。
```text
Host App
-> checks updates, downloads objects, stages incoming payload
Launcher
-> verifies signature, applies file map, switches deployment, rolls back
宿主应用
-> 检查更新、下载对象、暂存传入的负载
启动器
-> 验证签名、应用文件映射、切换部署、回滚
PLONDS.Api
-> read-only metadata projection for clients
-> 面向客户端的只读元数据投影
PLONDS.Tool
-> CI/release command surface
-> CI/发布命令界面
PLONDS.Core
-> hash/diff/object-repo/sign/publish implementation
-> 哈希/差异/对象仓库/签名/发布实现
PLONDS.Shared
-> protocol constants and DTOs
-> 协议常量和 DTO
```
Rules for v1:
## v1 规则
- Core protocol behavior should live in `Plonds.Core`, not in PowerShell scripts.
- `scripts/*.ps1` may remain only as thin wrappers for GitHub Actions and local convenience.
- Host keeps download responsibility.
- Launcher keeps apply, atomic switch, snapshot, and rollback responsibility.
- 核心协议行为应位于 `Plonds.Core` 中,而非 PowerShell 脚本。
- `scripts/*.ps1` 仅可作为 GitHub Actions 和本地便利的薄包装层保留。
- 宿主应用保留下载职责。
- 启动器保留应用、原子切换、快照和回滚职责。
## Storage Layout
## 存储布局
The first version keeps one fixed object root:
第一版本保持固定的对象根目录:
```text
lanmountain/update/
repo/sha256/<prefix>/<hash>
meta/channels/<channel>/<platform>/latest.json
meta/distributions/<distributionId>.json
installers/<platform>/<version>/...
repo/sha256/<前缀>/<哈希>
meta/channels/<频道>/<平台>/latest.json
meta/distributions/<分发ID>.json
installers/<平台>/<版本>/...
```
Planned but not enabled in v1:
已规划但 v1 中未启用:
```text
lanmountain/update/repo-compressed/<algo>/<prefix>/<hash>
lanmountain/update/patches/<algo>/<baseHash>/<targetHash>
lanmountain/update/repo-compressed/<算法>/<前缀>/<哈希>
lanmountain/update/patches/<算法>/<基础哈希>/<目标哈希>
```
## Public Endpoints
## 公共接口
The API base path is `/api/plonds/v1`.
API 基础路径为 `/api/plonds/v1`
- `GET /healthz`
- `GET /api/plonds/v1/metadata`
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...`
- `GET /api/plonds/v1/distributions/{distributionId}`
- `GET /healthz` - 健康检查
- `GET /api/plonds/v1/metadata` - 获取元数据目录
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - 获取指定频道和平台的最新版本
- `GET /api/plonds/v1/distributions/{distributionId}` - 获取指定分发版本的完整信息
## Local Run
## 本地运行
```powershell
dotnet run --project src/Plonds.Api
```
By default the API reads metadata from `sample-data`.
默认情况下API 从 `sample-data` 读取元数据。

View File

@@ -13,4 +13,11 @@ public sealed record PlondsGenerateOptions(
string? FileMapUrl = null,
string? FileMapSignatureUrl = null,
string? InstallerDirectory = null,
string? InstallerBaseUrl = null);
string? InstallerBaseUrl = null,
string IncrementalStrategy = "release-payload",
string? BaselineVersion = null,
string? BaselineRef = null,
string? SourceCommit = null,
bool IsFullPayloadRelease = false,
string? CommitRangeStart = null,
string? CommitRangeEnd = null);

View File

@@ -42,11 +42,16 @@ public sealed class PlondsGenerator
Directory.CreateDirectory(metaDistributionRoot);
Directory.CreateDirectory(metaChannelRoot);
var previousManifest = ScanDirectory(previousDirectory);
var previousManifest = options.IsFullPayloadRelease
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: ScanDirectory(previousDirectory);
var currentManifest = ScanDirectory(currentDirectory);
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
var publishedAt = DateTimeOffset.UtcNow;
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
? options.PreviousVersion
: options.BaselineVersion;
var fileMap = new FileMapDocument(
FormatVersion: "1.0",
@@ -69,7 +74,14 @@ public sealed class PlondsGenerator
Metadata: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
["mode"] = "file-object"
["mode"] = "file-object",
["baselineVersion"] = baselineVersion,
["incrementalStrategy"] = options.IncrementalStrategy,
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
["sourceCommit"] = options.SourceCommit ?? string.Empty,
["baselineRef"] = options.BaselineRef ?? string.Empty,
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
});
var distribution = new DistributionDocument(
@@ -83,7 +95,17 @@ public sealed class PlondsGenerator
Components: fileMap.Components,
InstallerMirrors: installerMirrors,
Capabilities: ["file-object"],
Metadata: new Dictionary<string, string> { ["protocol"] = "PLONDS" });
Metadata: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
["baselineVersion"] = baselineVersion,
["incrementalStrategy"] = options.IncrementalStrategy,
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
["sourceCommit"] = options.SourceCommit ?? string.Empty,
["baselineRef"] = options.BaselineRef ?? string.Empty,
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
});
var latest = new LatestPointerDocument(
DistributionId: distributionId,
@@ -225,6 +247,7 @@ public sealed class PlondsGenerator
Platform: platform,
Arch: ResolveArch(platform),
Url: url,
Name: fileName,
FileName: fileName,
Sha256: ComputeSha256(destinationPath),
Size: new FileInfo(destinationPath).Length));
@@ -345,6 +368,7 @@ public sealed class PlondsGenerator
string Platform,
string Arch,
string? Url,
string? Name,
string? FileName,
string? Sha256,
long Size);

View File

@@ -9,4 +9,11 @@ public sealed record PlondsPublishOptions(
string Channel = "stable",
string? BaselineRoot = null,
string? RepoBaseUrl = null,
string? InstallerBaseUrl = null);
string? InstallerBaseUrl = null,
string IncrementalStrategy = "release-payload",
string? BaselineVersion = null,
string? BaselineRef = null,
string? SourceCommit = null,
bool IsFullPayloadRelease = false,
string? CommitRangeStart = null,
string? CommitRangeEnd = null);

View File

@@ -82,7 +82,7 @@ public sealed class PlondsPublisher
CurrentDirectory: currentAppDirectory,
Platform: config.Platform,
OutputRoot: options.OutputRoot,
PreviousVersion: previousVersion,
PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
PreviousDirectory: previousDirectory,
Channel: options.Channel,
DistributionId: distributionId,
@@ -90,7 +90,14 @@ public sealed class PlondsPublisher
FileMapUrl: fileMapUrl,
FileMapSignatureUrl: fileMapSignatureUrl,
InstallerDirectory: installerSourceDirectory,
InstallerBaseUrl: installerBaseUrl));
InstallerBaseUrl: installerBaseUrl,
IncrementalStrategy: options.IncrementalStrategy,
BaselineVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
BaselineRef: options.BaselineRef,
SourceCommit: options.SourceCommit,
IsFullPayloadRelease: options.IsFullPayloadRelease,
CommitRangeStart: options.CommitRangeStart,
CommitRangeEnd: options.CommitRangeEnd));
_signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);

View File

@@ -86,7 +86,14 @@ internal static class PlondsCli
Channel: Get(options, "channel", "stable") ?? "stable",
BaselineRoot: Get(options, "baseline-root"),
RepoBaseUrl: Get(options, "repo-base-url"),
InstallerBaseUrl: Get(options, "installer-base-url")));
InstallerBaseUrl: Get(options, "installer-base-url"),
IncrementalStrategy: Get(options, "incremental-strategy", "release-payload") ?? "release-payload",
BaselineVersion: Get(options, "baseline-version"),
BaselineRef: Get(options, "baseline-ref"),
SourceCommit: Get(options, "source-commit"),
IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease,
CommitRangeStart: Get(options, "commit-range-start"),
CommitRangeEnd: Get(options, "commit-range-end")));
foreach (var result in results)
{

File diff suppressed because it is too large Load Diff