mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
ci.plonds
This commit is contained in:
141
.github/workflows/release.yml
vendored
141
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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` 读取元数据。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user