mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
ci.plonds
This commit is contained in:
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
@@ -17,6 +18,7 @@ namespace LanMountainDesktop.Services;
|
||||
public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
{
|
||||
private const string DefaultApiBasePath = "/api/plonds/v1";
|
||||
private const int MaxTransientRetryAttempts = 3;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
@@ -71,6 +73,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
|
||||
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
|
||||
var endpoint = ResolveEndpoint();
|
||||
var latestVersionText = "-";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
@@ -78,7 +81,7 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PLONDS endpoint is not configured.",
|
||||
@@ -89,8 +92,6 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
{
|
||||
var apiBasePath = ResolveApiBasePath();
|
||||
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
|
||||
var metadata = await GetJsonNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var channelId = ResolveChannelId(includePrerelease);
|
||||
var platform = ResolvePlatform();
|
||||
var latestUrl = BuildApiUrl(
|
||||
@@ -98,12 +99,14 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
apiBasePath,
|
||||
$"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
|
||||
|
||||
JsonElement latestNode;
|
||||
try
|
||||
{
|
||||
latestNode = await GetJsonNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.StartsWith("HTTP 204", StringComparison.OrdinalIgnoreCase))
|
||||
_ = await GetJsonNodeWithRetryAsync(metadataUrl, PlondsCheckStage.Metadata, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var latestDescriptor = await GetLatestDescriptorAsync(
|
||||
latestUrl,
|
||||
allowNoUpdateResponse: true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (latestDescriptor is null)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
@@ -116,35 +119,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var latestVersionText = ReadString(latestNode, "version") ?? "-";
|
||||
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PLONDS latest distribution version is invalid.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var distributionId = ReadString(latestNode, "distributionId");
|
||||
if (string.IsNullOrWhiteSpace(distributionId))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PLONDS latest distribution id is missing.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
|
||||
var hasUpdate = latestVersion > normalizedCurrentVersion;
|
||||
latestVersionText = latestDescriptor.VersionText;
|
||||
var hasUpdate = latestDescriptor.Version > normalizedCurrentVersion;
|
||||
if (!isForce && !hasUpdate)
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
@@ -158,58 +134,67 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
ForceMode: false);
|
||||
}
|
||||
|
||||
var distributionUrl = BuildApiUrl(
|
||||
var distribution = await ResolveDistributionAsync(
|
||||
endpoint,
|
||||
apiBasePath,
|
||||
$"distributions/{Uri.EscapeDataString(distributionId)}");
|
||||
var distributionNode = await GetJsonNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
|
||||
latestUrl,
|
||||
latestDescriptor,
|
||||
channelId,
|
||||
platform,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var assets = ResolveInstallerAssets(distributionNode);
|
||||
var payload = ResolvePlondsPayload(distributionNode, distributionId, channelId, platform);
|
||||
if (assets.Count == 0 && !HasPlondsPayload(payload))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: "PLONDS distribution response does not expose downloadable update assets.",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
latestVersionText = distribution.Latest.VersionText;
|
||||
|
||||
var publishedAt = ParsePublishedAt(distributionNode) ?? DateTimeOffset.UtcNow;
|
||||
var publishedAt = ParsePublishedAt(distribution.DistributionNode) ?? DateTimeOffset.UtcNow;
|
||||
var release = new GitHubReleaseInfo(
|
||||
TagName: $"v{latestVersionText}",
|
||||
Name: $"PLONDS Distribution {latestVersionText}",
|
||||
TagName: $"v{distribution.Latest.VersionText}",
|
||||
Name: $"PLONDS Distribution {distribution.Latest.VersionText}",
|
||||
IsPrerelease: includePrerelease,
|
||||
IsDraft: false,
|
||||
PublishedAt: publishedAt,
|
||||
Assets: assets);
|
||||
Assets: distribution.Assets);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
LatestVersionText: distribution.Latest.VersionText,
|
||||
Release: release,
|
||||
PreferredAsset: SelectPreferredInstallerAsset(assets),
|
||||
PreferredAsset: SelectPreferredInstallerAsset(distribution.Assets),
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce,
|
||||
PlondsPayload: payload);
|
||||
PlondsPayload: distribution.Payload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (PlondsRequestException ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PLONDS",
|
||||
$"PLONDS {GetStageName(ex.Stage)} stage failed. {ex.Message}",
|
||||
ex);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: "-",
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: $"PLONDS {GetStageName(ex.Stage)} failed: {ex.Message}",
|
||||
ForceMode: isForce);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PLONDS", "PLONDS request failed with an unexpected error.", ex);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
IsUpdateAvailable: false,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: null,
|
||||
PreferredAsset: null,
|
||||
ErrorMessage: $"PLONDS request failed: {ex.Message}",
|
||||
@@ -217,7 +202,125 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetJsonNodeAsync(string url, CancellationToken cancellationToken)
|
||||
private async Task<LatestDescriptor?> GetLatestDescriptorAsync(
|
||||
string latestUrl,
|
||||
bool allowNoUpdateResponse,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var latestNode = await GetJsonNodeWithRetryAsync(
|
||||
latestUrl,
|
||||
PlondsCheckStage.Latest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ParseLatestDescriptor(latestNode);
|
||||
}
|
||||
catch (PlondsRequestException ex)
|
||||
when (allowNoUpdateResponse &&
|
||||
ex.Stage == PlondsCheckStage.Latest &&
|
||||
ex.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DistributionDescriptor> ResolveDistributionAsync(
|
||||
string endpoint,
|
||||
string apiBasePath,
|
||||
string latestUrl,
|
||||
LatestDescriptor latest,
|
||||
string channelId,
|
||||
string platform,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentLatest = latest;
|
||||
var hasRefreshedLatest = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var distributionUrl = BuildApiUrl(
|
||||
endpoint,
|
||||
apiBasePath,
|
||||
$"distributions/{Uri.EscapeDataString(currentLatest.DistributionId)}");
|
||||
|
||||
try
|
||||
{
|
||||
var distributionNode = await GetJsonNodeWithRetryAsync(
|
||||
distributionUrl,
|
||||
PlondsCheckStage.Distribution,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (TryCreateDistributionDescriptor(
|
||||
distributionNode,
|
||||
currentLatest,
|
||||
channelId,
|
||||
platform,
|
||||
out var descriptor,
|
||||
out var descriptorError))
|
||||
{
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
if (hasRefreshedLatest || descriptorError is null || !IsRecoverableDistributionError(descriptorError))
|
||||
{
|
||||
throw descriptorError ?? new PlondsRequestException(
|
||||
PlondsCheckStage.PayloadParse,
|
||||
"PLONDS distribution payload is incomplete.");
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"PLONDS",
|
||||
$"PLONDS distribution '{currentLatest.DistributionId}' is incomplete. Refreshing latest pointer once before failing.");
|
||||
}
|
||||
catch (PlondsRequestException ex) when (!hasRefreshedLatest && IsRecoverableDistributionError(ex))
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PLONDS",
|
||||
$"PLONDS distribution fetch for '{currentLatest.DistributionId}' failed during {GetStageName(ex.Stage)}. Refreshing latest pointer once. Details: {ex.Message}");
|
||||
}
|
||||
|
||||
hasRefreshedLatest = true;
|
||||
currentLatest = await GetLatestDescriptorAsync(
|
||||
latestUrl,
|
||||
allowNoUpdateResponse: false,
|
||||
cancellationToken).ConfigureAwait(false)
|
||||
?? throw new PlondsRequestException(
|
||||
PlondsCheckStage.Latest,
|
||||
"PLONDS latest pointer disappeared while recovering the distribution payload.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetJsonNodeWithRetryAsync(
|
||||
string url,
|
||||
PlondsCheckStage stage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PlondsRequestException? lastError = null;
|
||||
|
||||
for (var attempt = 1; attempt <= MaxTransientRetryAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await GetJsonNodeAsync(url, stage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PlondsRequestException ex) when (attempt < MaxTransientRetryAttempts && ex.IsTransient)
|
||||
{
|
||||
lastError = ex;
|
||||
AppLogger.Warn(
|
||||
"PLONDS",
|
||||
$"PLONDS {GetStageName(stage)} attempt {attempt}/{MaxTransientRetryAttempts} failed. Retrying shortly. Details: {ex.Message}");
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new PlondsRequestException(stage, "PLONDS request failed before a response was returned.");
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetJsonNodeAsync(
|
||||
string url,
|
||||
PlondsCheckStage stage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var token = ResolveToken();
|
||||
@@ -226,22 +329,127 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}");
|
||||
response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new PlondsRequestException(stage, "Request timed out.", isTransient: true, innerException: ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new PlondsRequestException(stage, $"Network error: {ex.Message}", isTransient: true, innerException: ex);
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("content", out var content))
|
||||
using (response)
|
||||
{
|
||||
return content.Clone();
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
stage,
|
||||
"HTTP 204: no content.",
|
||||
statusCode: response.StatusCode,
|
||||
isTransient: false);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
stage,
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}",
|
||||
statusCode: response.StatusCode,
|
||||
isTransient: IsTransientStatusCode(response.StatusCode));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("content", out var content))
|
||||
{
|
||||
return content.Clone();
|
||||
}
|
||||
|
||||
return root.Clone();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
stage,
|
||||
$"Invalid JSON response: {ex.Message}",
|
||||
isTransient: IsLikelyIncompleteJson(body),
|
||||
innerException: ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LatestDescriptor ParseLatestDescriptor(JsonElement latestNode)
|
||||
{
|
||||
var latestVersionText = ReadString(latestNode, "version") ?? "-";
|
||||
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
PlondsCheckStage.Latest,
|
||||
$"PLONDS latest distribution version is invalid: '{latestVersionText}'.");
|
||||
}
|
||||
|
||||
return root.Clone();
|
||||
var distributionId = ReadString(latestNode, "distributionId");
|
||||
if (string.IsNullOrWhiteSpace(distributionId))
|
||||
{
|
||||
throw new PlondsRequestException(
|
||||
PlondsCheckStage.Latest,
|
||||
"PLONDS latest distribution id is missing.");
|
||||
}
|
||||
|
||||
return new LatestDescriptor(distributionId, latestVersionText, latestVersion);
|
||||
}
|
||||
|
||||
private static bool TryCreateDistributionDescriptor(
|
||||
JsonElement distributionNode,
|
||||
LatestDescriptor latest,
|
||||
string channelId,
|
||||
string platform,
|
||||
out DistributionDescriptor descriptor,
|
||||
out PlondsRequestException? error)
|
||||
{
|
||||
descriptor = default!;
|
||||
error = null;
|
||||
|
||||
var assets = ResolveInstallerAssets(distributionNode);
|
||||
var payload = ResolvePlondsPayload(
|
||||
distributionNode,
|
||||
latest.DistributionId,
|
||||
channelId,
|
||||
platform);
|
||||
|
||||
if (assets.Count == 0 && !HasPlondsPayload(payload))
|
||||
{
|
||||
error = new PlondsRequestException(
|
||||
PlondsCheckStage.PayloadParse,
|
||||
"PLONDS distribution response does not expose downloadable update assets.");
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor = new DistributionDescriptor(latest, distributionNode, assets, payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsRecoverableDistributionError(PlondsRequestException error)
|
||||
{
|
||||
if (error.Stage == PlondsCheckStage.PayloadParse)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return error.Stage == PlondsCheckStage.Distribution &&
|
||||
(error.StatusCode == HttpStatusCode.NotFound ||
|
||||
error.StatusCode == HttpStatusCode.RequestTimeout ||
|
||||
error.StatusCode == HttpStatusCode.TooManyRequests ||
|
||||
error.StatusCode is >= HttpStatusCode.InternalServerError);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode)
|
||||
@@ -258,7 +466,8 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = ReadString(installerNode, "name");
|
||||
var name = ReadString(installerNode, "name")
|
||||
?? ReadString(installerNode, "fileName");
|
||||
var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl");
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
@@ -593,4 +802,91 @@ public sealed class PlondsReleaseUpdateService : IDisposable
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
|
||||
private static bool IsTransientStatusCode(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode == HttpStatusCode.RequestTimeout ||
|
||||
statusCode == HttpStatusCode.TooManyRequests ||
|
||||
statusCode >= HttpStatusCode.InternalServerError;
|
||||
}
|
||||
|
||||
private static bool IsLikelyIncompleteJson(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var trimmed = body.TrimEnd();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var last = trimmed[^1];
|
||||
return last != '}' && last != ']';
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
return attempt switch
|
||||
{
|
||||
1 => TimeSpan.FromMilliseconds(350),
|
||||
2 => TimeSpan.FromMilliseconds(900),
|
||||
_ => TimeSpan.FromMilliseconds(1500)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStageName(PlondsCheckStage stage)
|
||||
{
|
||||
return stage switch
|
||||
{
|
||||
PlondsCheckStage.Metadata => "metadata",
|
||||
PlondsCheckStage.Latest => "latest",
|
||||
PlondsCheckStage.Distribution => "distribution",
|
||||
PlondsCheckStage.PayloadParse => "payload-parse",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private enum PlondsCheckStage
|
||||
{
|
||||
Metadata,
|
||||
Latest,
|
||||
Distribution,
|
||||
PayloadParse
|
||||
}
|
||||
|
||||
private sealed record LatestDescriptor(
|
||||
string DistributionId,
|
||||
string VersionText,
|
||||
Version Version);
|
||||
|
||||
private sealed record DistributionDescriptor(
|
||||
LatestDescriptor Latest,
|
||||
JsonElement DistributionNode,
|
||||
IReadOnlyList<GitHubReleaseAsset> Assets,
|
||||
PlondsUpdatePayload Payload);
|
||||
|
||||
private sealed class PlondsRequestException : Exception
|
||||
{
|
||||
public PlondsRequestException(
|
||||
PlondsCheckStage stage,
|
||||
string message,
|
||||
HttpStatusCode? statusCode = null,
|
||||
bool isTransient = false,
|
||||
Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Stage = stage;
|
||||
StatusCode = statusCode;
|
||||
IsTransient = isTransient;
|
||||
}
|
||||
|
||||
public PlondsCheckStage Stage { get; }
|
||||
|
||||
public HttpStatusCode? StatusCode { get; }
|
||||
|
||||
public bool IsTransient { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,6 +915,25 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
|
||||
|
||||
var githubFallbackResult = isForce
|
||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
|
||||
if (githubFallbackResult.Success)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"UpdateSettings",
|
||||
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
return githubFallbackResult;
|
||||
}
|
||||
|
||||
return isForce
|
||||
|
||||
@@ -71,6 +71,7 @@ public sealed class UpdateWorkflowService
|
||||
};
|
||||
|
||||
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
|
||||
private const int MaxPlondsOuterRetryAttempts = 3;
|
||||
|
||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
@@ -251,7 +252,12 @@ public sealed class UpdateWorkflowService
|
||||
var payload = checkResult.PlondsPayload;
|
||||
if (payload is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "PLONDS payload is missing.");
|
||||
return await HandlePlondsDeltaFailureAsync(
|
||||
checkResult,
|
||||
"payload-parse",
|
||||
"PLONDS payload is missing.",
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var incomingDir = GetLauncherIncomingDirectory();
|
||||
@@ -264,7 +270,12 @@ public sealed class UpdateWorkflowService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
|
||||
return await HandlePlondsDeltaFailureAsync(
|
||||
checkResult,
|
||||
"payload-parse",
|
||||
$"Failed to create incoming directory: {ex.Message}",
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -279,18 +290,31 @@ public sealed class UpdateWorkflowService
|
||||
payload.FileMapJson,
|
||||
payload.FileMapJsonUrl,
|
||||
fileMapPath,
|
||||
"file map",
|
||||
"filemap-download",
|
||||
cancellationToken);
|
||||
|
||||
var fileMapSignature = await EnsurePlondsTextResourceAsync(
|
||||
payload.FileMapSignature,
|
||||
payload.FileMapSignatureUrl,
|
||||
signaturePath,
|
||||
"file map signature",
|
||||
"filemap-download",
|
||||
cancellationToken);
|
||||
|
||||
var downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
|
||||
IReadOnlyList<PlondsDownloadEntry> downloadEntries;
|
||||
try
|
||||
{
|
||||
downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new PlondsDownloadException("payload-parse", $"PLONDS file map JSON is invalid: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (downloadEntries.Count == 0)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "PLONDS file map does not contain downloadable objects.");
|
||||
throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
|
||||
}
|
||||
|
||||
var expectedObjectCount = downloadEntries.Count;
|
||||
@@ -310,46 +334,13 @@ public sealed class UpdateWorkflowService
|
||||
continue;
|
||||
}
|
||||
|
||||
var destinationPath = GetPlondsObjectDestinationPath(objectsDir, entry.ObjectHashHex);
|
||||
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
}
|
||||
|
||||
if (File.Exists(destinationPath))
|
||||
{
|
||||
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
|
||||
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
|
||||
var downloadResult = await PlondsDownloadService.DownloadAsync(
|
||||
entry.DownloadUrl,
|
||||
destinationPath,
|
||||
downloadOptions,
|
||||
null,
|
||||
var objectInfo = await EnsurePlondsObjectAsync(
|
||||
entry,
|
||||
objectsDir,
|
||||
downloadThreads,
|
||||
cancellationToken);
|
||||
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"Failed to download PLONDS object {entry.RelativePath}: {downloadResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(actualHash) &&
|
||||
!string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
|
||||
}
|
||||
|
||||
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
|
||||
objectResults.Add(objectInfo);
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
}
|
||||
@@ -390,8 +381,20 @@ public sealed class UpdateWorkflowService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", "Failed to download PLONDS incremental payload.", ex);
|
||||
return new UpdateDownloadResult(false, null, ex.Message);
|
||||
var stage = ex is PlondsDownloadException plondsException
|
||||
? plondsException.Stage
|
||||
: "payload-parse";
|
||||
var message = ex is PlondsDownloadException
|
||||
? ex.Message
|
||||
: $"PLONDS incremental payload failed unexpectedly: {ex.Message}";
|
||||
|
||||
AppLogger.Warn("UpdateWorkflow", $"Failed to download PLONDS incremental payload at stage '{stage}'.", ex);
|
||||
return await HandlePlondsDeltaFailureAsync(
|
||||
checkResult,
|
||||
stage,
|
||||
message,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +423,125 @@ public sealed class UpdateWorkflowService
|
||||
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<UpdateDownloadResult> DownloadFullInstallerAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken,
|
||||
bool forceRedownload)
|
||||
{
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
||||
}
|
||||
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var existingPending = GetPendingUpdate(state);
|
||||
|
||||
if (!forceRedownload &&
|
||||
existingPending is not null &&
|
||||
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
|
||||
File.Exists(existingPending.InstallerPath))
|
||||
{
|
||||
var verifyResult = await VerifyPendingUpdateAsync();
|
||||
if (verifyResult.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(
|
||||
true,
|
||||
existingPending.InstallerPath,
|
||||
null,
|
||||
verifyResult.HashMatched,
|
||||
verifyResult.ExpectedHash,
|
||||
verifyResult.ActualHash);
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateWorkflow",
|
||||
$"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
|
||||
}
|
||||
|
||||
if (forceRedownload && existingPending is not null && File.Exists(existingPending.InstallerPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(existingPending.InstallerPath);
|
||||
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
|
||||
}
|
||||
|
||||
ClearPendingUpdate();
|
||||
state = _settingsFacade.Update.Get();
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_updatesDirectory);
|
||||
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
|
||||
var destinationPath = Path.Combine(_updatesDirectory, fileName);
|
||||
|
||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||
checkResult.PreferredAsset,
|
||||
destinationPath,
|
||||
state.UpdateDownloadSource,
|
||||
state.UpdateDownloadThreads,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<UpdateDownloadResult> HandlePlondsDeltaFailureAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
string stage,
|
||||
string errorMessage,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedMessage = string.IsNullOrWhiteSpace(errorMessage)
|
||||
? $"PLONDS {stage} failed."
|
||||
: $"PLONDS {stage} failed: {errorMessage}";
|
||||
|
||||
if (checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, normalizedMessage);
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"UpdateWorkflow",
|
||||
$"PLONDS delta download failed at stage '{stage}'. Falling back to full installer download. Details: {errorMessage}");
|
||||
|
||||
var fallbackResult = await DownloadFullInstallerAsync(
|
||||
checkResult,
|
||||
progress,
|
||||
cancellationToken,
|
||||
forceRedownload: false);
|
||||
|
||||
if (fallbackResult.Success)
|
||||
{
|
||||
return fallbackResult;
|
||||
}
|
||||
|
||||
var combinedMessage = string.IsNullOrWhiteSpace(fallbackResult.ErrorMessage)
|
||||
? normalizedMessage
|
||||
: $"{normalizedMessage} Full installer fallback failed: {fallbackResult.ErrorMessage}";
|
||||
|
||||
return new UpdateDownloadResult(false, null, combinedMessage);
|
||||
}
|
||||
|
||||
private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex)
|
||||
{
|
||||
var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
|
||||
@@ -431,6 +553,8 @@ public sealed class UpdateWorkflowService
|
||||
string? inlineContent,
|
||||
string? sourceUrl,
|
||||
string destinationPath,
|
||||
string resourceName,
|
||||
string stage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(inlineContent))
|
||||
@@ -441,20 +565,131 @@ public sealed class UpdateWorkflowService
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceUrl))
|
||||
{
|
||||
throw new InvalidOperationException("PLONDS payload does not contain a file map source.");
|
||||
throw new PlondsDownloadException(stage, $"PLONDS payload does not contain a {resourceName} source.");
|
||||
}
|
||||
|
||||
var downloadResult = await PlondsDownloadService.DownloadAsync(
|
||||
sourceUrl,
|
||||
destinationPath,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (!downloadResult.Success)
|
||||
Exception? lastError = null;
|
||||
for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to download PLONDS file map resource: {downloadResult.ErrorMessage}");
|
||||
var downloadResult = await PlondsDownloadService.DownloadAsync(
|
||||
sourceUrl,
|
||||
destinationPath,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (downloadResult.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (attempt < MaxPlondsOuterRetryAttempts)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS {resourceName}.");
|
||||
}
|
||||
|
||||
if (attempt < MaxPlondsOuterRetryAttempts)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"UpdateWorkflow",
|
||||
$"PLONDS {resourceName} download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed. Retrying same URL.");
|
||||
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
|
||||
throw new PlondsDownloadException(
|
||||
stage,
|
||||
$"Failed to download PLONDS {resourceName} from {sourceUrl}.",
|
||||
lastError);
|
||||
}
|
||||
|
||||
private static async Task<PlondsDownloadedObjectInfo> EnsurePlondsObjectAsync(
|
||||
PlondsDownloadEntry entry,
|
||||
string objectsDirectory,
|
||||
int downloadThreads,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var destinationPath = GetPlondsObjectDestinationPath(objectsDirectory, entry.ObjectHashHex);
|
||||
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
}
|
||||
|
||||
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
|
||||
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(existingHash))
|
||||
{
|
||||
DeleteFileIfExists(destinationPath);
|
||||
}
|
||||
|
||||
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
|
||||
var allowForcedRedownload = true;
|
||||
Exception? lastError = null;
|
||||
|
||||
for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
|
||||
{
|
||||
var downloadResult = await PlondsDownloadService.DownloadAsync(
|
||||
entry.DownloadUrl,
|
||||
destinationPath,
|
||||
downloadOptions,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
lastError = new InvalidOperationException(downloadResult.ErrorMessage ?? $"Failed to download PLONDS object {entry.RelativePath}.");
|
||||
if (attempt < MaxPlondsOuterRetryAttempts)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"UpdateWorkflow",
|
||||
$"PLONDS object download attempt {attempt}/{MaxPlondsOuterRetryAttempts} failed for {entry.RelativePath}. Retrying.");
|
||||
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new PlondsDownloadException(
|
||||
"object-download",
|
||||
$"Failed to download PLONDS object {entry.RelativePath}.",
|
||||
lastError);
|
||||
}
|
||||
|
||||
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(actualHash) &&
|
||||
string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath);
|
||||
}
|
||||
|
||||
DeleteFileIfExists(destinationPath);
|
||||
var mismatchMessage = $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash ?? "<missing>"}";
|
||||
lastError = new InvalidOperationException(mismatchMessage);
|
||||
|
||||
if (allowForcedRedownload)
|
||||
{
|
||||
allowForcedRedownload = false;
|
||||
AppLogger.Warn(
|
||||
"UpdateWorkflow",
|
||||
$"{mismatchMessage}. Removing the bad object and forcing one clean re-download.");
|
||||
await Task.Delay(GetPlondsRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new PlondsDownloadException("object-verify", mismatchMessage, lastError);
|
||||
}
|
||||
|
||||
throw new PlondsDownloadException(
|
||||
"object-download",
|
||||
$"Failed to download PLONDS object {entry.RelativePath}.",
|
||||
lastError);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
|
||||
@@ -628,6 +863,31 @@ public sealed class UpdateWorkflowService
|
||||
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void DeleteFileIfExists(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup only. The caller still verifies the resulting payload before it is applied.
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan GetPlondsRetryDelay(int attempt)
|
||||
{
|
||||
return attempt switch
|
||||
{
|
||||
1 => TimeSpan.FromMilliseconds(350),
|
||||
2 => TimeSpan.FromMilliseconds(900),
|
||||
_ => TimeSpan.FromMilliseconds(1500)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (node.ValueKind == JsonValueKind.Object)
|
||||
@@ -742,6 +1002,17 @@ public sealed class UpdateWorkflowService
|
||||
string DownloadUrl,
|
||||
string ObjectHashHex);
|
||||
|
||||
private sealed class PlondsDownloadException : Exception
|
||||
{
|
||||
public PlondsDownloadException(string stage, string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Stage = stage;
|
||||
}
|
||||
|
||||
public string Stage { get; }
|
||||
}
|
||||
|
||||
private sealed record PlondsDownloadedObjectInfo(
|
||||
string ComponentId,
|
||||
string RelativePath,
|
||||
@@ -876,53 +1147,11 @@ public sealed class UpdateWorkflowService
|
||||
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
||||
}
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
||||
}
|
||||
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var existingPending = GetPendingUpdate(state);
|
||||
if (existingPending is not null &&
|
||||
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
|
||||
File.Exists(existingPending.InstallerPath))
|
||||
{
|
||||
var verifyResult = await VerifyPendingUpdateAsync();
|
||||
if (verifyResult.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
|
||||
}
|
||||
|
||||
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_updatesDirectory);
|
||||
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
|
||||
var destinationPath = Path.Combine(_updatesDirectory, fileName);
|
||||
|
||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||
checkResult.PreferredAsset,
|
||||
destinationPath,
|
||||
state.UpdateDownloadSource,
|
||||
state.UpdateDownloadThreads,
|
||||
return await DownloadFullInstallerAsync(
|
||||
checkResult,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
cancellationToken,
|
||||
forceRedownload: false);
|
||||
}
|
||||
|
||||
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
|
||||
@@ -938,58 +1167,11 @@ public sealed class UpdateWorkflowService
|
||||
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
||||
}
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
||||
}
|
||||
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var existingPending = GetPendingUpdate(state);
|
||||
|
||||
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(existingPending.InstallerPath);
|
||||
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
ClearPendingUpdate();
|
||||
|
||||
Directory.CreateDirectory(_updatesDirectory);
|
||||
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
|
||||
var destinationPath = Path.Combine(_updatesDirectory, fileName);
|
||||
|
||||
state = _settingsFacade.Update.Get();
|
||||
|
||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||
checkResult.PreferredAsset,
|
||||
destinationPath,
|
||||
state.UpdateDownloadSource,
|
||||
state.UpdateDownloadThreads,
|
||||
return await DownloadFullInstallerAsync(
|
||||
checkResult,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
cancellationToken,
|
||||
forceRedownload: true);
|
||||
}
|
||||
|
||||
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
|
||||
|
||||
Reference in New Issue
Block a user