mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00: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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -15,6 +15,23 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
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:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
@@ -32,6 +49,11 @@ jobs:
|
|||||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository metadata
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Get release info
|
- name: Get release info
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -47,7 +69,11 @@ jobs:
|
|||||||
else
|
else
|
||||||
TAG="v${RAW_TAG}"
|
TAG="v${RAW_TAG}"
|
||||||
fi
|
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
|
fi
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
|
||||||
@@ -120,14 +146,18 @@ jobs:
|
|||||||
-p:IncludeNativeLibrariesForSelfExtract=true `
|
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||||
-p:EnableCompressionInSingleFile=true `
|
-p:EnableCompressionInSingleFile=true `
|
||||||
-p:DebugType=none `
|
-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) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error "Launcher AOT publish failed"
|
Write-Error "Launcher AOT publish failed"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 閺勫墽銇氶崣鎴濈缂佹挻鐏?
|
# é<EFBFBD>„剧ã<EFBFBD>šé<EFBFBD>™æˆ<EFBFBD>ç«·ç¼<EFBFBD>æ’´ç<EFBFBD>?
|
||||||
Write-Host "Launcher published to: $launcherPublishDir"
|
Write-Host "Launcher published to: $launcherPublishDir"
|
||||||
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
|
||||||
if ($exeFile) {
|
if ($exeFile) {
|
||||||
@@ -384,7 +414,7 @@ jobs:
|
|||||||
- name: Publish Launcher (AOT)
|
- name: Publish Launcher (AOT)
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing Launcher with AOT for Linux x64..."
|
echo "Publishing Launcher with AOT for Linux x64..."
|
||||||
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/launcher-linux-x64 \
|
-o ./publish/launcher-linux-x64 \
|
||||||
@@ -395,13 +425,17 @@ jobs:
|
|||||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
-p:EnableCompressionInSingleFile=true \
|
-p:EnableCompressionInSingleFile=true \
|
||||||
-p:DebugType=none \
|
-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
|
if [ $? -ne 0 ]; then
|
||||||
echo "Launcher AOT publish failed"
|
echo "Launcher AOT publish failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Launcher published to: ./publish/launcher-linux-x64"
|
echo "Launcher published to: ./publish/launcher-linux-x64"
|
||||||
ls -lh ./publish/launcher-linux-x64/
|
ls -lh ./publish/launcher-linux-x64/
|
||||||
|
|
||||||
@@ -587,7 +621,7 @@ jobs:
|
|||||||
- name: Publish Launcher (AOT)
|
- name: Publish Launcher (AOT)
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
|
echo "Publishing Launcher with AOT for macOS ${{ matrix.arch }}..."
|
||||||
|
|
||||||
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
-o ./publish/launcher-macos-${{ matrix.arch }} \
|
||||||
@@ -598,13 +632,17 @@ jobs:
|
|||||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||||
-p:EnableCompressionInSingleFile=true \
|
-p:EnableCompressionInSingleFile=true \
|
||||||
-p:DebugType=none \
|
-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
|
if [ $? -ne 0 ]; then
|
||||||
echo "Launcher AOT publish failed"
|
echo "Launcher AOT publish failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
|
echo "Launcher published to: ./publish/launcher-macos-${{ matrix.arch }}"
|
||||||
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
|
ls -lh ./publish/launcher-macos-${{ matrix.arch }}/
|
||||||
|
|
||||||
@@ -737,6 +775,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
@@ -816,6 +858,21 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = "Stop"
|
$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 `
|
./scripts/Publish-Plonds.ps1 `
|
||||||
-Version $env:VERSION `
|
-Version $env:VERSION `
|
||||||
@@ -826,7 +883,14 @@ jobs:
|
|||||||
-Channel "stable" `
|
-Channel "stable" `
|
||||||
-S3Endpoint $env:S3_ENDPOINT `
|
-S3Endpoint $env:S3_ENDPOINT `
|
||||||
-S3Bucket $env:S3_BUCKET `
|
-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
|
- name: Upload PLONDS assets
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -872,7 +936,7 @@ jobs:
|
|||||||
echo "Organizing artifacts..."
|
echo "Organizing artifacts..."
|
||||||
mkdir -p release-files
|
mkdir -p release-files
|
||||||
find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} 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 ""
|
||||||
echo "Files ready for release:"
|
echo "Files ready for release:"
|
||||||
ls -lh release-files/ || echo "No files found in release-files"
|
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.
|
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-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-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**
|
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
|
||||||
@@ -923,3 +995,42 @@ jobs:
|
|||||||
See commits for changes.
|
See commits for changes.
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
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.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -17,6 +18,7 @@ namespace LanMountainDesktop.Services;
|
|||||||
public sealed class PlondsReleaseUpdateService : IDisposable
|
public sealed class PlondsReleaseUpdateService : IDisposable
|
||||||
{
|
{
|
||||||
private const string DefaultApiBasePath = "/api/plonds/v1";
|
private const string DefaultApiBasePath = "/api/plonds/v1";
|
||||||
|
private const int MaxTransientRetryAttempts = 3;
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly bool _ownsHttpClient;
|
private readonly bool _ownsHttpClient;
|
||||||
@@ -71,6 +73,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||||
var endpoint = ResolveEndpoint();
|
var endpoint = ResolveEndpoint();
|
||||||
|
var latestVersionText = "-";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(endpoint))
|
if (string.IsNullOrWhiteSpace(endpoint))
|
||||||
{
|
{
|
||||||
@@ -78,7 +81,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
Success: false,
|
Success: false,
|
||||||
IsUpdateAvailable: false,
|
IsUpdateAvailable: false,
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
CurrentVersionText: normalizedCurrentVersionText,
|
||||||
LatestVersionText: "-",
|
LatestVersionText: latestVersionText,
|
||||||
Release: null,
|
Release: null,
|
||||||
PreferredAsset: null,
|
PreferredAsset: null,
|
||||||
ErrorMessage: "PLONDS endpoint is not configured.",
|
ErrorMessage: "PLONDS endpoint is not configured.",
|
||||||
@@ -89,8 +92,6 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
{
|
{
|
||||||
var apiBasePath = ResolveApiBasePath();
|
var apiBasePath = ResolveApiBasePath();
|
||||||
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
|
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
|
||||||
var metadata = await GetJsonNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var channelId = ResolveChannelId(includePrerelease);
|
var channelId = ResolveChannelId(includePrerelease);
|
||||||
var platform = ResolvePlatform();
|
var platform = ResolvePlatform();
|
||||||
var latestUrl = BuildApiUrl(
|
var latestUrl = BuildApiUrl(
|
||||||
@@ -98,12 +99,14 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
apiBasePath,
|
apiBasePath,
|
||||||
$"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
|
$"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
|
||||||
|
|
||||||
JsonElement latestNode;
|
_ = await GetJsonNodeWithRetryAsync(metadataUrl, PlondsCheckStage.Metadata, cancellationToken).ConfigureAwait(false);
|
||||||
try
|
|
||||||
{
|
var latestDescriptor = await GetLatestDescriptorAsync(
|
||||||
latestNode = await GetJsonNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
|
latestUrl,
|
||||||
}
|
allowNoUpdateResponse: true,
|
||||||
catch (InvalidOperationException ex) when (ex.Message.StartsWith("HTTP 204", StringComparison.OrdinalIgnoreCase))
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (latestDescriptor is null)
|
||||||
{
|
{
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -116,35 +119,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
ForceMode: isForce);
|
ForceMode: isForce);
|
||||||
}
|
}
|
||||||
|
|
||||||
var latestVersionText = ReadString(latestNode, "version") ?? "-";
|
latestVersionText = latestDescriptor.VersionText;
|
||||||
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
|
var hasUpdate = latestDescriptor.Version > normalizedCurrentVersion;
|
||||||
{
|
|
||||||
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;
|
|
||||||
if (!isForce && !hasUpdate)
|
if (!isForce && !hasUpdate)
|
||||||
{
|
{
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
@@ -158,58 +134,67 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
ForceMode: false);
|
ForceMode: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var distributionUrl = BuildApiUrl(
|
var distribution = await ResolveDistributionAsync(
|
||||||
endpoint,
|
endpoint,
|
||||||
apiBasePath,
|
apiBasePath,
|
||||||
$"distributions/{Uri.EscapeDataString(distributionId)}");
|
latestUrl,
|
||||||
var distributionNode = await GetJsonNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
|
latestDescriptor,
|
||||||
|
channelId,
|
||||||
|
platform,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var assets = ResolveInstallerAssets(distributionNode);
|
latestVersionText = distribution.Latest.VersionText;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
var publishedAt = ParsePublishedAt(distributionNode) ?? DateTimeOffset.UtcNow;
|
var publishedAt = ParsePublishedAt(distribution.DistributionNode) ?? DateTimeOffset.UtcNow;
|
||||||
var release = new GitHubReleaseInfo(
|
var release = new GitHubReleaseInfo(
|
||||||
TagName: $"v{latestVersionText}",
|
TagName: $"v{distribution.Latest.VersionText}",
|
||||||
Name: $"PLONDS Distribution {latestVersionText}",
|
Name: $"PLONDS Distribution {distribution.Latest.VersionText}",
|
||||||
IsPrerelease: includePrerelease,
|
IsPrerelease: includePrerelease,
|
||||||
IsDraft: false,
|
IsDraft: false,
|
||||||
PublishedAt: publishedAt,
|
PublishedAt: publishedAt,
|
||||||
Assets: assets);
|
Assets: distribution.Assets);
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: true,
|
Success: true,
|
||||||
IsUpdateAvailable: true,
|
IsUpdateAvailable: true,
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
CurrentVersionText: normalizedCurrentVersionText,
|
||||||
LatestVersionText: latestVersionText,
|
LatestVersionText: distribution.Latest.VersionText,
|
||||||
Release: release,
|
Release: release,
|
||||||
PreferredAsset: SelectPreferredInstallerAsset(assets),
|
PreferredAsset: SelectPreferredInstallerAsset(distribution.Assets),
|
||||||
ErrorMessage: null,
|
ErrorMessage: null,
|
||||||
ForceMode: isForce,
|
ForceMode: isForce,
|
||||||
PlondsPayload: payload);
|
PlondsPayload: distribution.Payload);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (PlondsRequestException ex)
|
||||||
{
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PLONDS",
|
||||||
|
$"PLONDS {GetStageName(ex.Stage)} stage failed. {ex.Message}",
|
||||||
|
ex);
|
||||||
|
|
||||||
return new UpdateCheckResult(
|
return new UpdateCheckResult(
|
||||||
Success: false,
|
Success: false,
|
||||||
IsUpdateAvailable: false,
|
IsUpdateAvailable: false,
|
||||||
CurrentVersionText: normalizedCurrentVersionText,
|
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,
|
Release: null,
|
||||||
PreferredAsset: null,
|
PreferredAsset: null,
|
||||||
ErrorMessage: $"PLONDS request failed: {ex.Message}",
|
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);
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
var token = ResolveToken();
|
var token = ResolveToken();
|
||||||
@@ -226,22 +329,127 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
HttpResponseMessage response;
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
try
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
{
|
||||||
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);
|
using (response)
|
||||||
var root = document.RootElement;
|
|
||||||
if (root.ValueKind == JsonValueKind.Object &&
|
|
||||||
root.TryGetProperty("content", out var content))
|
|
||||||
{
|
{
|
||||||
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)
|
private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode)
|
||||||
@@ -258,7 +466,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = ReadString(installerNode, "name");
|
var name = ReadString(installerNode, "name")
|
||||||
|
?? ReadString(installerNode, "fileName");
|
||||||
var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl");
|
var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl");
|
||||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
|
||||||
{
|
{
|
||||||
@@ -593,4 +802,91 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
|||||||
|
|
||||||
return value[..maxLength];
|
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(
|
AppLogger.Warn(
|
||||||
"UpdateSettings",
|
"UpdateSettings",
|
||||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
$"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
|
return isForce
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public sealed class UpdateWorkflowService
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
|
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
|
||||||
|
private const int MaxPlondsOuterRetryAttempts = 3;
|
||||||
|
|
||||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||||
{
|
{
|
||||||
@@ -251,7 +252,12 @@ public sealed class UpdateWorkflowService
|
|||||||
var payload = checkResult.PlondsPayload;
|
var payload = checkResult.PlondsPayload;
|
||||||
if (payload is null)
|
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();
|
var incomingDir = GetLauncherIncomingDirectory();
|
||||||
@@ -264,7 +270,12 @@ public sealed class UpdateWorkflowService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
try
|
||||||
@@ -279,18 +290,31 @@ public sealed class UpdateWorkflowService
|
|||||||
payload.FileMapJson,
|
payload.FileMapJson,
|
||||||
payload.FileMapJsonUrl,
|
payload.FileMapJsonUrl,
|
||||||
fileMapPath,
|
fileMapPath,
|
||||||
|
"file map",
|
||||||
|
"filemap-download",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var fileMapSignature = await EnsurePlondsTextResourceAsync(
|
var fileMapSignature = await EnsurePlondsTextResourceAsync(
|
||||||
payload.FileMapSignature,
|
payload.FileMapSignature,
|
||||||
payload.FileMapSignatureUrl,
|
payload.FileMapSignatureUrl,
|
||||||
signaturePath,
|
signaturePath,
|
||||||
|
"file map signature",
|
||||||
|
"filemap-download",
|
||||||
cancellationToken);
|
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)
|
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;
|
var expectedObjectCount = downloadEntries.Count;
|
||||||
@@ -310,46 +334,13 @@ public sealed class UpdateWorkflowService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var destinationPath = GetPlondsObjectDestinationPath(objectsDir, entry.ObjectHashHex);
|
var objectInfo = await EnsurePlondsObjectAsync(
|
||||||
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
entry,
|
||||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
objectsDir,
|
||||||
{
|
downloadThreads,
|
||||||
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,
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
if (!downloadResult.Success)
|
objectResults.Add(objectInfo);
|
||||||
{
|
|
||||||
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));
|
|
||||||
completedItems++;
|
completedItems++;
|
||||||
progress?.Report((double)completedItems / totalSteps);
|
progress?.Report((double)completedItems / totalSteps);
|
||||||
}
|
}
|
||||||
@@ -390,8 +381,20 @@ public sealed class UpdateWorkflowService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("UpdateWorkflow", "Failed to download PLONDS incremental payload.", ex);
|
var stage = ex is PlondsDownloadException plondsException
|
||||||
return new UpdateDownloadResult(false, null, ex.Message);
|
? 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);
|
|| 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)
|
private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex)
|
||||||
{
|
{
|
||||||
var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
|
var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
|
||||||
@@ -431,6 +553,8 @@ public sealed class UpdateWorkflowService
|
|||||||
string? inlineContent,
|
string? inlineContent,
|
||||||
string? sourceUrl,
|
string? sourceUrl,
|
||||||
string destinationPath,
|
string destinationPath,
|
||||||
|
string resourceName,
|
||||||
|
string stage,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(inlineContent))
|
if (!string.IsNullOrWhiteSpace(inlineContent))
|
||||||
@@ -441,20 +565,131 @@ public sealed class UpdateWorkflowService
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(sourceUrl))
|
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(
|
Exception? lastError = null;
|
||||||
sourceUrl,
|
for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
|
||||||
destinationPath,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
|
|
||||||
if (!downloadResult.Success)
|
|
||||||
{
|
{
|
||||||
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)
|
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
|
||||||
@@ -628,6 +863,31 @@ public sealed class UpdateWorkflowService
|
|||||||
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
|
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)
|
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
|
||||||
{
|
{
|
||||||
if (node.ValueKind == JsonValueKind.Object)
|
if (node.ValueKind == JsonValueKind.Object)
|
||||||
@@ -742,6 +1002,17 @@ public sealed class UpdateWorkflowService
|
|||||||
string DownloadUrl,
|
string DownloadUrl,
|
||||||
string ObjectHashHex);
|
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(
|
private sealed record PlondsDownloadedObjectInfo(
|
||||||
string ComponentId,
|
string ComponentId,
|
||||||
string RelativePath,
|
string RelativePath,
|
||||||
@@ -876,53 +1147,11 @@ public sealed class UpdateWorkflowService
|
|||||||
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
return await DownloadFullInstallerAsync(
|
||||||
{
|
checkResult,
|
||||||
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,
|
|
||||||
progress,
|
progress,
|
||||||
cancellationToken);
|
cancellationToken,
|
||||||
|
forceRedownload: false);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
|
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
|
||||||
@@ -938,58 +1167,11 @@ public sealed class UpdateWorkflowService
|
|||||||
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
return await DownloadFullInstallerAsync(
|
||||||
{
|
checkResult,
|
||||||
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,
|
|
||||||
progress,
|
progress,
|
||||||
cancellationToken);
|
cancellationToken,
|
||||||
|
forceRedownload: true);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
|
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
|
||||||
|
|||||||
@@ -1965,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.PreferredAsset is null)
|
if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result))
|
||||||
{
|
{
|
||||||
UpdateStatus = isForce
|
UpdateStatus = isForce
|
||||||
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
|
? 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))]
|
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
|
||||||
private async Task RedownloadUpdateAsync()
|
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.");
|
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
|
||||||
return;
|
return;
|
||||||
@@ -2233,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
UpdateDownloadResult downloadResult;
|
UpdateDownloadResult downloadResult;
|
||||||
|
|
||||||
// Prefer delta update if available (smaller download, faster)
|
// 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...");
|
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
|
||||||
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
|
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
|
||||||
if (!downloadResult.Success)
|
if (!downloadResult.Success && result.PlondsPayload is null)
|
||||||
{
|
{
|
||||||
// Delta download failed, fall back to full installer
|
// Delta download failed, fall back to full installer
|
||||||
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling 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
|
```text
|
||||||
PenguinLogisticsOnlineNetworkDistributionSystem/
|
PenguinLogisticsOnlineNetworkDistributionSystem/
|
||||||
@@ -22,72 +22,72 @@ PenguinLogisticsOnlineNetworkDistributionSystem/
|
|||||||
distributions/
|
distributions/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Projects
|
## 项目说明
|
||||||
|
|
||||||
- `Plonds.Shared` provides protocol constants and models.
|
- `Plonds.Shared` 提供协议常量和数据模型。
|
||||||
- `Plonds.Core` owns hashing, diffing, object-repo generation, manifest generation, signing, and publish orchestration.
|
- `Plonds.Core` 负责哈希计算、差异生成、对象仓库生成、清单生成、签名和发布编排。
|
||||||
- `Plonds.Tool` is the CI-facing CLI entrypoint. PowerShell should stay as a thin wrapper around this tool.
|
- `Plonds.Tool` 是面向 CI 的命令行入口。PowerShell 脚本应保持为围绕此工具的薄包装层。
|
||||||
- `Plonds.Api` is a thin read-only API that reads metadata from a local folder laid out like S3.
|
- `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
|
```text
|
||||||
Host App
|
宿主应用
|
||||||
-> checks updates, downloads objects, stages incoming payload
|
-> 检查更新、下载对象、暂存传入的负载
|
||||||
Launcher
|
启动器
|
||||||
-> verifies signature, applies file map, switches deployment, rolls back
|
-> 验证签名、应用文件映射、切换部署、回滚
|
||||||
|
|
||||||
PLONDS.Api
|
PLONDS.Api
|
||||||
-> read-only metadata projection for clients
|
-> 面向客户端的只读元数据投影
|
||||||
PLONDS.Tool
|
PLONDS.Tool
|
||||||
-> CI/release command surface
|
-> CI/发布命令界面
|
||||||
PLONDS.Core
|
PLONDS.Core
|
||||||
-> hash/diff/object-repo/sign/publish implementation
|
-> 哈希/差异/对象仓库/签名/发布实现
|
||||||
PLONDS.Shared
|
PLONDS.Shared
|
||||||
-> protocol constants and DTOs
|
-> 协议常量和 DTO
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules for v1:
|
## v1 规则
|
||||||
|
|
||||||
- Core protocol behavior should live in `Plonds.Core`, not in PowerShell scripts.
|
- 核心协议行为应位于 `Plonds.Core` 中,而非 PowerShell 脚本。
|
||||||
- `scripts/*.ps1` may remain only as thin wrappers for GitHub Actions and local convenience.
|
- `scripts/*.ps1` 仅可作为 GitHub Actions 和本地便利的薄包装层保留。
|
||||||
- Host keeps download responsibility.
|
- 宿主应用保留下载职责。
|
||||||
- Launcher keeps apply, atomic switch, snapshot, and rollback responsibility.
|
- 启动器保留应用、原子切换、快照和回滚职责。
|
||||||
|
|
||||||
## Storage Layout
|
## 存储布局
|
||||||
|
|
||||||
The first version keeps one fixed object root:
|
第一版本保持固定的对象根目录:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
lanmountain/update/
|
lanmountain/update/
|
||||||
repo/sha256/<prefix>/<hash>
|
repo/sha256/<前缀>/<哈希>
|
||||||
meta/channels/<channel>/<platform>/latest.json
|
meta/channels/<频道>/<平台>/latest.json
|
||||||
meta/distributions/<distributionId>.json
|
meta/distributions/<分发ID>.json
|
||||||
installers/<platform>/<version>/...
|
installers/<平台>/<版本>/...
|
||||||
```
|
```
|
||||||
|
|
||||||
Planned but not enabled in v1:
|
已规划但 v1 中未启用:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
lanmountain/update/repo-compressed/<algo>/<prefix>/<hash>
|
lanmountain/update/repo-compressed/<算法>/<前缀>/<哈希>
|
||||||
lanmountain/update/patches/<algo>/<baseHash>/<targetHash>
|
lanmountain/update/patches/<算法>/<基础哈希>/<目标哈希>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Public Endpoints
|
## 公共接口
|
||||||
|
|
||||||
The API base path is `/api/plonds/v1`.
|
API 基础路径为 `/api/plonds/v1`。
|
||||||
|
|
||||||
- `GET /healthz`
|
- `GET /healthz` - 健康检查
|
||||||
- `GET /api/plonds/v1/metadata`
|
- `GET /api/plonds/v1/metadata` - 获取元数据目录
|
||||||
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...`
|
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - 获取指定频道和平台的最新版本
|
||||||
- `GET /api/plonds/v1/distributions/{distributionId}`
|
- `GET /api/plonds/v1/distributions/{distributionId}` - 获取指定分发版本的完整信息
|
||||||
|
|
||||||
## Local Run
|
## 本地运行
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project src/Plonds.Api
|
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? FileMapUrl = null,
|
||||||
string? FileMapSignatureUrl = null,
|
string? FileMapSignatureUrl = null,
|
||||||
string? InstallerDirectory = 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(metaDistributionRoot);
|
||||||
Directory.CreateDirectory(metaChannelRoot);
|
Directory.CreateDirectory(metaChannelRoot);
|
||||||
|
|
||||||
var previousManifest = ScanDirectory(previousDirectory);
|
var previousManifest = options.IsFullPayloadRelease
|
||||||
|
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
: ScanDirectory(previousDirectory);
|
||||||
var currentManifest = ScanDirectory(currentDirectory);
|
var currentManifest = ScanDirectory(currentDirectory);
|
||||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
|
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
|
||||||
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
|
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
|
||||||
var publishedAt = DateTimeOffset.UtcNow;
|
var publishedAt = DateTimeOffset.UtcNow;
|
||||||
|
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
|
||||||
|
? options.PreviousVersion
|
||||||
|
: options.BaselineVersion;
|
||||||
|
|
||||||
var fileMap = new FileMapDocument(
|
var fileMap = new FileMapDocument(
|
||||||
FormatVersion: "1.0",
|
FormatVersion: "1.0",
|
||||||
@@ -69,7 +74,14 @@ public sealed class PlondsGenerator
|
|||||||
Metadata: new Dictionary<string, string>
|
Metadata: new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["protocol"] = "PLONDS",
|
["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(
|
var distribution = new DistributionDocument(
|
||||||
@@ -83,7 +95,17 @@ public sealed class PlondsGenerator
|
|||||||
Components: fileMap.Components,
|
Components: fileMap.Components,
|
||||||
InstallerMirrors: installerMirrors,
|
InstallerMirrors: installerMirrors,
|
||||||
Capabilities: ["file-object"],
|
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(
|
var latest = new LatestPointerDocument(
|
||||||
DistributionId: distributionId,
|
DistributionId: distributionId,
|
||||||
@@ -225,6 +247,7 @@ public sealed class PlondsGenerator
|
|||||||
Platform: platform,
|
Platform: platform,
|
||||||
Arch: ResolveArch(platform),
|
Arch: ResolveArch(platform),
|
||||||
Url: url,
|
Url: url,
|
||||||
|
Name: fileName,
|
||||||
FileName: fileName,
|
FileName: fileName,
|
||||||
Sha256: ComputeSha256(destinationPath),
|
Sha256: ComputeSha256(destinationPath),
|
||||||
Size: new FileInfo(destinationPath).Length));
|
Size: new FileInfo(destinationPath).Length));
|
||||||
@@ -345,6 +368,7 @@ public sealed class PlondsGenerator
|
|||||||
string Platform,
|
string Platform,
|
||||||
string Arch,
|
string Arch,
|
||||||
string? Url,
|
string? Url,
|
||||||
|
string? Name,
|
||||||
string? FileName,
|
string? FileName,
|
||||||
string? Sha256,
|
string? Sha256,
|
||||||
long Size);
|
long Size);
|
||||||
|
|||||||
@@ -9,4 +9,11 @@ public sealed record PlondsPublishOptions(
|
|||||||
string Channel = "stable",
|
string Channel = "stable",
|
||||||
string? BaselineRoot = null,
|
string? BaselineRoot = null,
|
||||||
string? RepoBaseUrl = 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,
|
CurrentDirectory: currentAppDirectory,
|
||||||
Platform: config.Platform,
|
Platform: config.Platform,
|
||||||
OutputRoot: options.OutputRoot,
|
OutputRoot: options.OutputRoot,
|
||||||
PreviousVersion: previousVersion,
|
PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
|
||||||
PreviousDirectory: previousDirectory,
|
PreviousDirectory: previousDirectory,
|
||||||
Channel: options.Channel,
|
Channel: options.Channel,
|
||||||
DistributionId: distributionId,
|
DistributionId: distributionId,
|
||||||
@@ -90,7 +90,14 @@ public sealed class PlondsPublisher
|
|||||||
FileMapUrl: fileMapUrl,
|
FileMapUrl: fileMapUrl,
|
||||||
FileMapSignatureUrl: fileMapSignatureUrl,
|
FileMapSignatureUrl: fileMapSignatureUrl,
|
||||||
InstallerDirectory: installerSourceDirectory,
|
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);
|
_signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,14 @@ internal static class PlondsCli
|
|||||||
Channel: Get(options, "channel", "stable") ?? "stable",
|
Channel: Get(options, "channel", "stable") ?? "stable",
|
||||||
BaselineRoot: Get(options, "baseline-root"),
|
BaselineRoot: Get(options, "baseline-root"),
|
||||||
RepoBaseUrl: Get(options, "repo-base-url"),
|
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)
|
foreach (var result in results)
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user