ci.plonds

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

View File

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

View File

@@ -915,6 +915,25 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
AppLogger.Warn(
"UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
{
AppLogger.Info(
"UpdateSettings",
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
}
else
{
AppLogger.Warn(
"UpdateSettings",
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
}
return githubFallbackResult;
}
return isForce

View File

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