mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout.
1573 lines
56 KiB
C#
1573 lines
56 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Runtime.InteropServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using LanMountainDesktop.PluginSdk;
|
|
using LanMountainDesktop.Services.Settings;
|
|
|
|
namespace LanMountainDesktop.Services;
|
|
|
|
public sealed record UpdatePendingInfo(
|
|
string InstallerPath,
|
|
string VersionText,
|
|
DateTimeOffset? PublishedAt,
|
|
string? Sha256 = null);
|
|
|
|
public sealed record UpdateVerifyResult(
|
|
bool Success,
|
|
bool HashMatched,
|
|
string? ExpectedHash,
|
|
string? ActualHash,
|
|
string? ErrorMessage);
|
|
|
|
public sealed record UpdateInstallerLaunchResult(
|
|
bool Success,
|
|
bool UserCancelledElevation,
|
|
string? ErrorMessage);
|
|
|
|
internal static class HostUpdateWorkflowServiceProvider
|
|
{
|
|
private static readonly object Gate = new();
|
|
private static UpdateWorkflowService? _instance;
|
|
|
|
public static UpdateWorkflowService GetOrCreate()
|
|
{
|
|
lock (Gate)
|
|
{
|
|
return _instance ??= new UpdateWorkflowService(HostSettingsFacadeProvider.GetOrCreate());
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class UpdateWorkflowService
|
|
{
|
|
private readonly ISettingsFacadeService _settingsFacade;
|
|
private readonly string _updatesDirectory;
|
|
|
|
private const string LauncherDirectoryName = ".Launcher";
|
|
private const string UpdateDirectoryName = "update";
|
|
private const string IncomingDirectoryName = "incoming";
|
|
private const string IncomingObjectsDirectoryName = "objects";
|
|
private const string SignedFileMapName = "files.json";
|
|
private const string SignedFileMapSignatureName = "files.json.sig";
|
|
private const string UpdateArchiveName = "update.zip";
|
|
private const string PlondsFileMapName = "plonds-filemap.json";
|
|
private const string PlondsFileMapSignatureName = "plonds-filemap.sig";
|
|
private const string PlondsUpdateStateName = "plonds-update.json";
|
|
private const string PlondsUpdateArchiveName = "plonds-update.zip";
|
|
|
|
private static readonly HttpClient PlondsHttpClient = new()
|
|
{
|
|
Timeout = TimeSpan.FromMinutes(5)
|
|
};
|
|
|
|
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
|
|
private const int MaxPlondsOuterRetryAttempts = 3;
|
|
|
|
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
|
{
|
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
|
_updatesDirectory = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"LanMountainDesktop",
|
|
"Updates");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the path to the Launcher's incoming update directory where delta packages should be placed.
|
|
/// </summary>
|
|
public static string GetLauncherIncomingDirectory()
|
|
{
|
|
// The app runs from app-{version}/ subdirectory; Launcher root is one level up.
|
|
var appBaseDir = AppContext.BaseDirectory;
|
|
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
|
if (string.IsNullOrWhiteSpace(launcherRoot))
|
|
{
|
|
launcherRoot = appBaseDir;
|
|
}
|
|
return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
|
|
}
|
|
|
|
public static string GetLauncherIncomingObjectsDirectory()
|
|
{
|
|
return Path.Combine(GetLauncherIncomingDirectory(), IncomingObjectsDirectoryName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates.
|
|
/// </summary>
|
|
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
|
{
|
|
if (release is null || release.Assets is null || release.Assets.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
|
|
}
|
|
|
|
public static bool IsDeltaUpdateAvailable(UpdateCheckResult checkResult)
|
|
{
|
|
if (checkResult.PlondsPayload is not null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return checkResult.Release is not null && IsDeltaUpdateAvailable(checkResult.Release);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads signed file-map assets to the Launcher's incoming directory.
|
|
/// </summary>
|
|
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
|
UpdateCheckResult checkResult,
|
|
IProgress<double>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(checkResult);
|
|
|
|
if (!checkResult.Success || !checkResult.IsUpdateAvailable)
|
|
{
|
|
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
|
}
|
|
|
|
if (checkResult.PlondsPayload is null && checkResult.Release is null)
|
|
{
|
|
return new UpdateDownloadResult(false, null, "No update payload is available for delta download.");
|
|
}
|
|
|
|
if (checkResult.PlondsPayload is not null)
|
|
{
|
|
return await DownloadPlondsDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
|
}
|
|
|
|
var release = checkResult.Release;
|
|
if (release is null ||
|
|
!TryResolveDeltaAssets(release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
|
|
{
|
|
return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
|
|
}
|
|
|
|
var incomingDir = GetLauncherIncomingDirectory();
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(incomingDir);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
|
|
}
|
|
|
|
var state = _settingsFacade.Update.Get();
|
|
var downloadSource = state.UseGhProxyMirror
|
|
? UpdateSettingsValues.DownloadSourceGhProxy
|
|
: UpdateSettingsValues.DownloadSourceGitHub;
|
|
var downloadThreads = state.UpdateDownloadThreads;
|
|
|
|
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
|
{
|
|
(manifestAsset, SignedFileMapName),
|
|
(signatureAsset, SignedFileMapSignatureName),
|
|
(archiveAsset, UpdateArchiveName)
|
|
};
|
|
|
|
var totalAssets = requiredAssets.Count;
|
|
var completedAssets = 0;
|
|
|
|
foreach (var (asset, destinationFileName) in requiredAssets)
|
|
{
|
|
var destinationPath = Path.Combine(incomingDir, destinationFileName);
|
|
|
|
// Skip if already downloaded and file exists
|
|
if (File.Exists(destinationPath))
|
|
{
|
|
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
|
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping.");
|
|
completedAssets++;
|
|
progress?.Report((double)completedAssets / totalAssets);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
var assetProgress = progress is null ? null : new Progress<double>(p =>
|
|
{
|
|
var overallProgress = ((double)completedAssets + p) / totalAssets;
|
|
progress.Report(overallProgress);
|
|
});
|
|
|
|
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
|
asset,
|
|
destinationPath,
|
|
downloadSource,
|
|
downloadThreads,
|
|
assetProgress,
|
|
cancellationToken);
|
|
|
|
if (!result.Success)
|
|
{
|
|
// Clean up partially downloaded files
|
|
foreach (var file in requiredAssets.Select(a => a.DestinationFileName))
|
|
{
|
|
try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
|
|
}
|
|
return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}");
|
|
}
|
|
|
|
completedAssets++;
|
|
progress?.Report((double)completedAssets / totalAssets);
|
|
}
|
|
|
|
// Save state indicating a signed file-map update is pending.
|
|
SaveState(state with
|
|
{
|
|
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
|
|
PendingUpdateVersion = checkResult.LatestVersionText,
|
|
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
|
? publishedAt.ToUnixTimeMilliseconds()
|
|
: null,
|
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
PendingUpdateSha256 = null
|
|
});
|
|
|
|
AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
|
|
|
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
|
|
}
|
|
|
|
private async Task<UpdateDownloadResult> DownloadPlondsDeltaUpdateAsync(
|
|
UpdateCheckResult checkResult,
|
|
IProgress<double>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var payload = checkResult.PlondsPayload;
|
|
if (payload is null)
|
|
{
|
|
return await HandlePlondsDeltaFailureAsync(
|
|
checkResult,
|
|
"payload-parse",
|
|
"PLONDS payload is missing.",
|
|
progress,
|
|
cancellationToken);
|
|
}
|
|
|
|
var incomingDir = GetLauncherIncomingDirectory();
|
|
var objectsDir = GetLauncherIncomingObjectsDirectory();
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(incomingDir);
|
|
Directory.CreateDirectory(objectsDir);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await HandlePlondsDeltaFailureAsync(
|
|
checkResult,
|
|
"payload-parse",
|
|
$"Failed to create incoming directory: {ex.Message}",
|
|
progress,
|
|
cancellationToken);
|
|
}
|
|
|
|
try
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var downloadThreads = Math.Max(1, state.UpdateDownloadThreads);
|
|
var fileMapPath = Path.Combine(incomingDir, PlondsFileMapName);
|
|
var signaturePath = Path.Combine(incomingDir, PlondsFileMapSignatureName);
|
|
var updateStatePath = Path.Combine(incomingDir, PlondsUpdateStateName);
|
|
|
|
var fileMapJson = await EnsurePlondsTextResourceAsync(
|
|
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);
|
|
|
|
IReadOnlyList<PlondsDownloadedObjectInfo> objectResults;
|
|
if (!string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl))
|
|
{
|
|
progress?.Report(2d / 3d);
|
|
objectResults = await EnsurePlondsArchiveObjectsAsync(
|
|
payload,
|
|
incomingDir,
|
|
objectsDir,
|
|
state.UseGhProxyMirror
|
|
? UpdateSettingsValues.DownloadSourceGhProxy
|
|
: UpdateSettingsValues.DownloadSourceGitHub,
|
|
downloadThreads,
|
|
progress,
|
|
cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
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)
|
|
{
|
|
throw new PlondsDownloadException("payload-parse", "PLONDS file map does not contain downloadable objects.");
|
|
}
|
|
|
|
var expectedObjectCount = downloadEntries.Count;
|
|
var completedItems = 2;
|
|
progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2));
|
|
|
|
var downloadResults = new List<PlondsDownloadedObjectInfo>(expectedObjectCount);
|
|
var objectTargets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var totalSteps = expectedObjectCount + 2;
|
|
|
|
foreach (var entry in downloadEntries)
|
|
{
|
|
if (!objectTargets.Add(entry.ObjectHashHex))
|
|
{
|
|
completedItems++;
|
|
progress?.Report((double)completedItems / totalSteps);
|
|
continue;
|
|
}
|
|
|
|
var objectInfo = await EnsurePlondsObjectAsync(
|
|
entry,
|
|
objectsDir,
|
|
downloadThreads,
|
|
cancellationToken);
|
|
|
|
downloadResults.Add(objectInfo);
|
|
completedItems++;
|
|
progress?.Report((double)completedItems / totalSteps);
|
|
}
|
|
|
|
objectResults = downloadResults;
|
|
}
|
|
|
|
var updateState = new PlondsUpdateState(
|
|
checkResult.LatestVersionText,
|
|
payload.DistributionId,
|
|
payload.ChannelId,
|
|
payload.SubChannel,
|
|
fileMapPath,
|
|
signaturePath,
|
|
objectsDir,
|
|
DateTimeOffset.UtcNow,
|
|
fileMapJson,
|
|
fileMapSignature,
|
|
objectResults);
|
|
|
|
await File.WriteAllTextAsync(updateStatePath, JsonSerializer.Serialize(updateState, UpdateJsonOptions), cancellationToken);
|
|
|
|
SaveState(state with
|
|
{
|
|
PendingUpdateInstallerPath = updateStatePath,
|
|
PendingUpdateVersion = checkResult.LatestVersionText,
|
|
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
|
? publishedAt.ToUnixTimeMilliseconds()
|
|
: null,
|
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
PendingUpdateSha256 = null
|
|
});
|
|
|
|
progress?.Report(1d);
|
|
AppLogger.Info("UpdateWorkflow", $"PLONDS update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
|
return new UpdateDownloadResult(true, updateStatePath, null);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions UpdateJsonOptions = new()
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
|
|
/// <summary>
|
|
/// Checks whether the pending update is managed by Launcher incoming payload.
|
|
/// </summary>
|
|
public bool IsPendingDeltaUpdate()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var pendingPath = state.PendingUpdateInstallerPath?.Trim();
|
|
if (string.IsNullOrWhiteSpace(pendingPath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Incoming payload updates are identified by the local manifest or incoming directory path.
|
|
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|
|
|| pendingPath.EndsWith(PlondsUpdateStateName, StringComparison.OrdinalIgnoreCase)
|
|
|| pendingPath.EndsWith(PlondsFileMapName, StringComparison.OrdinalIgnoreCase)
|
|
|| pendingPath.EndsWith(PlondsFileMapSignatureName, 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.UseGhProxyMirror
|
|
? UpdateSettingsValues.DownloadSourceGhProxy
|
|
: UpdateSettingsValues.DownloadSourceGitHub,
|
|
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();
|
|
var shard = normalizedHash.Length >= 2 ? normalizedHash[..2] : normalizedHash;
|
|
return Path.Combine(objectsDirectory, shard, normalizedHash);
|
|
}
|
|
|
|
private static async Task<string> EnsurePlondsTextResourceAsync(
|
|
string? inlineContent,
|
|
string? sourceUrl,
|
|
string destinationPath,
|
|
string resourceName,
|
|
string stage,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(inlineContent))
|
|
{
|
|
await File.WriteAllTextAsync(destinationPath, inlineContent, cancellationToken);
|
|
return inlineContent;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(sourceUrl))
|
|
{
|
|
throw new PlondsDownloadException(stage, $"PLONDS payload does not contain a {resourceName} source.");
|
|
}
|
|
|
|
Exception? lastError = null;
|
|
for (var attempt = 1; attempt <= MaxPlondsOuterRetryAttempts; attempt++)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 async Task<IReadOnlyList<PlondsDownloadedObjectInfo>> EnsurePlondsArchiveObjectsAsync(
|
|
PlondsUpdatePayload payload,
|
|
string incomingDirectory,
|
|
string objectsDirectory,
|
|
string downloadSource,
|
|
int downloadThreads,
|
|
IProgress<double>? progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(payload.UpdateArchiveUrl))
|
|
{
|
|
throw new PlondsDownloadException("payload-parse", "PLONDS payload does not contain an update archive URL.");
|
|
}
|
|
|
|
var archiveAsset = new GitHubReleaseAsset(
|
|
Name: Path.GetFileName(payload.UpdateArchiveUrl) ?? PlondsUpdateArchiveName,
|
|
BrowserDownloadUrl: payload.UpdateArchiveUrl,
|
|
SizeBytes: payload.UpdateArchiveSizeBytes ?? 0,
|
|
Sha256: payload.UpdateArchiveSha256);
|
|
var archivePath = Path.Combine(incomingDirectory, PlondsUpdateArchiveName);
|
|
var archiveProgress = progress is null
|
|
? null
|
|
: new Progress<double>(p => progress.Report((2d + p) / 3d));
|
|
|
|
var downloadResult = await _settingsFacade.Update.DownloadAssetAsync(
|
|
archiveAsset,
|
|
archivePath,
|
|
downloadSource,
|
|
downloadThreads,
|
|
archiveProgress,
|
|
cancellationToken);
|
|
|
|
if (!downloadResult.Success)
|
|
{
|
|
downloadResult = await _settingsFacade.Update.RedownloadAssetAsync(
|
|
archiveAsset,
|
|
archivePath,
|
|
downloadSource,
|
|
downloadThreads,
|
|
archiveProgress,
|
|
cancellationToken);
|
|
}
|
|
|
|
if (!downloadResult.Success)
|
|
{
|
|
throw new PlondsDownloadException(
|
|
"object-download",
|
|
$"Failed to download PLONDS update archive: {downloadResult.ErrorMessage}");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (Directory.Exists(objectsDirectory))
|
|
{
|
|
Directory.Delete(objectsDirectory, recursive: true);
|
|
}
|
|
|
|
Directory.CreateDirectory(objectsDirectory);
|
|
ZipFile.ExtractToDirectory(archivePath, objectsDirectory, overwriteFiles: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new PlondsDownloadException(
|
|
"payload-parse",
|
|
$"Failed to extract PLONDS update archive: {ex.Message}",
|
|
ex);
|
|
}
|
|
finally
|
|
{
|
|
DeleteFileIfExists(archivePath);
|
|
}
|
|
|
|
var objectResults = Directory.EnumerateFiles(objectsDirectory, "*", SearchOption.AllDirectories)
|
|
.Select(path => new PlondsDownloadedObjectInfo(
|
|
ComponentId: "app",
|
|
RelativePath: Path.GetRelativePath(objectsDirectory, path).Replace('\\', '/'),
|
|
SourceUrl: payload.UpdateArchiveUrl,
|
|
ObjectHashHex: Path.GetFileName(path),
|
|
LocalPath: path))
|
|
.ToArray();
|
|
|
|
progress?.Report(1d);
|
|
return objectResults;
|
|
}
|
|
|
|
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
|
|
{
|
|
var entries = new List<PlondsDownloadEntry>();
|
|
if (string.IsNullOrWhiteSpace(fileMapJson))
|
|
{
|
|
return entries;
|
|
}
|
|
|
|
using var document = JsonDocument.Parse(fileMapJson);
|
|
var root = document.RootElement;
|
|
if (root.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return entries;
|
|
}
|
|
|
|
if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode))
|
|
{
|
|
return entries;
|
|
}
|
|
|
|
if (componentsNode.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var component in componentsNode.EnumerateObject())
|
|
{
|
|
if (component.Value.ValueKind != JsonValueKind.Object)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
AppendDownloadEntries(entries, component.Name, filesNode);
|
|
}
|
|
}
|
|
else if (componentsNode.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var component in componentsNode.EnumerateArray())
|
|
{
|
|
if (component.ValueKind != JsonValueKind.Object)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var componentId = ReadStringIgnoreCase(component, "id")
|
|
?? ReadStringIgnoreCase(component, "name")
|
|
?? "app";
|
|
if (!TryGetPropertyIgnoreCase(component, "files", out var filesNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
AppendDownloadEntries(entries, componentId, filesNode);
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
private static void AppendDownloadEntries(ICollection<PlondsDownloadEntry> entries, string componentId, JsonElement filesNode)
|
|
{
|
|
if (filesNode.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var fileEntry in filesNode.EnumerateObject())
|
|
{
|
|
if (fileEntry.Value.ValueKind != JsonValueKind.Object)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (TryCreateDownloadEntry(componentId, fileEntry.Name, fileEntry.Value, out var entry))
|
|
{
|
|
entries.Add(entry);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (filesNode.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var fileEntry in filesNode.EnumerateArray())
|
|
{
|
|
if (fileEntry.ValueKind != JsonValueKind.Object)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var relativePath = ReadStringIgnoreCase(fileEntry, "path");
|
|
if (TryCreateDownloadEntry(componentId, relativePath, fileEntry, out var entry))
|
|
{
|
|
entries.Add(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool TryCreateDownloadEntry(
|
|
string componentId,
|
|
string? relativePath,
|
|
JsonElement fileNode,
|
|
out PlondsDownloadEntry entry)
|
|
{
|
|
entry = default!;
|
|
|
|
var normalizedPath = string.IsNullOrWhiteSpace(relativePath)
|
|
? null
|
|
: relativePath.Trim();
|
|
var downloadUrl = ReadStringIgnoreCase(fileNode, "objecturl")
|
|
?? ReadStringIgnoreCase(fileNode, "downloadurl")
|
|
?? ReadStringIgnoreCase(fileNode, "archivedownloadurl")
|
|
?? ReadStringIgnoreCase(fileNode, "url");
|
|
var hashHex = ReadStringIgnoreCase(fileNode, "sha256")
|
|
?? ReadStringIgnoreCase(fileNode, "filesha256")
|
|
?? ReadStringIgnoreCase(fileNode, "contenthash");
|
|
|
|
if ((string.IsNullOrWhiteSpace(hashHex) || string.IsNullOrWhiteSpace(downloadUrl)) &&
|
|
TryGetPropertyIgnoreCase(fileNode, "hash", out var hashNode) &&
|
|
hashNode.ValueKind == JsonValueKind.Object)
|
|
{
|
|
var algorithm = ReadStringIgnoreCase(hashNode, "algorithm");
|
|
if (string.IsNullOrWhiteSpace(algorithm) ||
|
|
algorithm.Contains("sha256", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
hashHex ??= ReadStringIgnoreCase(hashNode, "value");
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(normalizedPath) ||
|
|
string.IsNullOrWhiteSpace(downloadUrl) ||
|
|
string.IsNullOrWhiteSpace(hashHex))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
entry = new PlondsDownloadEntry(
|
|
componentId,
|
|
normalizedPath,
|
|
downloadUrl,
|
|
NormalizeHashText(hashHex));
|
|
return true;
|
|
}
|
|
|
|
private static async Task<string?> ComputeFileSha256HexAsync(string filePath, CancellationToken cancellationToken)
|
|
{
|
|
if (!File.Exists(filePath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken);
|
|
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
|
}
|
|
|
|
private static string NormalizeHashText(string hash)
|
|
{
|
|
var normalized = hash.Trim();
|
|
var separator = normalized.IndexOf(':');
|
|
if (separator >= 0 && separator < normalized.Length - 1)
|
|
{
|
|
normalized = normalized[(separator + 1)..];
|
|
}
|
|
|
|
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)
|
|
{
|
|
foreach (var property in node.EnumerateObject())
|
|
{
|
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
value = property.Value;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
private static string? ReadStringIgnoreCase(JsonElement node, string propertyName)
|
|
{
|
|
return TryGetPropertyIgnoreCase(node, propertyName, out var value)
|
|
? value.ValueKind == JsonValueKind.String
|
|
? value.GetString()
|
|
: value.ToString()
|
|
: null;
|
|
}
|
|
|
|
private static byte[]? ReadByteArrayIgnoreCase(JsonElement node, string propertyName)
|
|
{
|
|
if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return ReadByteArray(value);
|
|
}
|
|
|
|
private static byte[]? ReadByteArray(JsonElement value)
|
|
{
|
|
switch (value.ValueKind)
|
|
{
|
|
case JsonValueKind.String:
|
|
{
|
|
var text = value.GetString()?.Trim();
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (IsHexString(text))
|
|
{
|
|
try
|
|
{
|
|
return Convert.FromHexString(text);
|
|
}
|
|
catch
|
|
{
|
|
// fall through to base64
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
return Convert.FromBase64String(text);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
case JsonValueKind.Array:
|
|
{
|
|
var bytes = new List<byte>();
|
|
foreach (var item in value.EnumerateArray())
|
|
{
|
|
if (!item.TryGetInt32(out var number) || number is < byte.MinValue or > byte.MaxValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
bytes.Add((byte)number);
|
|
}
|
|
|
|
return bytes.ToArray();
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static bool IsHexString(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value) || value.Length % 2 != 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var ch in value)
|
|
{
|
|
if (!Uri.IsHexDigit(ch))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private sealed record PlondsDownloadEntry(
|
|
string ComponentId,
|
|
string RelativePath,
|
|
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,
|
|
string SourceUrl,
|
|
string ObjectHashHex,
|
|
string LocalPath);
|
|
|
|
private sealed record PlondsUpdateState(
|
|
string VersionText,
|
|
string DistributionId,
|
|
string ChannelId,
|
|
string SubChannel,
|
|
string FileMapPath,
|
|
string FileMapSignaturePath,
|
|
string ObjectsDirectory,
|
|
DateTimeOffset DownloadedAtUtc,
|
|
string FileMapJson,
|
|
string FileMapSignature,
|
|
IReadOnlyList<PlondsDownloadedObjectInfo> Objects);
|
|
|
|
private static bool TryResolveDeltaAssets(
|
|
IReadOnlyList<GitHubReleaseAsset> assets,
|
|
out GitHubReleaseAsset manifestAsset,
|
|
out GitHubReleaseAsset signatureAsset,
|
|
out GitHubReleaseAsset archiveAsset)
|
|
{
|
|
manifestAsset = default!;
|
|
signatureAsset = default!;
|
|
archiveAsset = default!;
|
|
|
|
if (assets is null || assets.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var platformSuffix = GetPlatformAssetSuffix();
|
|
var platformManifest = $"files-{platformSuffix}.json";
|
|
var platformSignature = $"files-{platformSuffix}.json.sig";
|
|
var platformArchive = $"update-{platformSuffix}.zip";
|
|
|
|
var manifestCandidate = FindAsset(assets, platformManifest) ?? FindAsset(assets, SignedFileMapName);
|
|
var signatureCandidate = FindAsset(assets, platformSignature) ?? FindAsset(assets, SignedFileMapSignatureName);
|
|
var archiveCandidate = FindAsset(assets, platformArchive) ?? FindAsset(assets, UpdateArchiveName);
|
|
if (manifestCandidate is null || signatureCandidate is null || archiveCandidate is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
manifestAsset = manifestCandidate;
|
|
signatureAsset = signatureCandidate;
|
|
archiveAsset = archiveCandidate;
|
|
return true;
|
|
}
|
|
|
|
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string name)
|
|
{
|
|
return assets.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static string GetPlatformAssetSuffix()
|
|
{
|
|
var os = OperatingSystem.IsWindows()
|
|
? "windows"
|
|
: OperatingSystem.IsLinux()
|
|
? "linux"
|
|
: OperatingSystem.IsMacOS()
|
|
? "macos"
|
|
: "unknown";
|
|
|
|
var arch = RuntimeInformation.OSArchitecture switch
|
|
{
|
|
Architecture.X86 => "x86",
|
|
Architecture.Arm => "arm",
|
|
Architecture.Arm64 => "arm64",
|
|
_ => "x64"
|
|
};
|
|
|
|
return $"{os}-{arch}";
|
|
}
|
|
|
|
public UpdatePendingInfo? GetPendingUpdate()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
return GetPendingUpdate(state);
|
|
}
|
|
|
|
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
|
|
Version currentVersion,
|
|
bool isForce = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var includePrerelease = string.Equals(
|
|
UpdateSettingsValues.NormalizeChannel(state.UpdateChannel, state.IncludePrereleaseUpdates),
|
|
UpdateSettingsValues.ChannelPreview,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
var result = isForce
|
|
? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
|
|
currentVersion,
|
|
includePrerelease,
|
|
cancellationToken)
|
|
: await _settingsFacade.Update.CheckForUpdatesAsync(
|
|
currentVersion,
|
|
includePrerelease,
|
|
cancellationToken);
|
|
|
|
SaveState(state with
|
|
{
|
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
|
Version currentVersion,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
|
|
}
|
|
|
|
public async Task<UpdateDownloadResult> DownloadReleaseAsync(
|
|
UpdateCheckResult checkResult,
|
|
IProgress<double>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(checkResult);
|
|
|
|
if (checkResult.PlondsPayload is not null)
|
|
{
|
|
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
|
}
|
|
|
|
return await DownloadFullInstallerAsync(
|
|
checkResult,
|
|
progress,
|
|
cancellationToken,
|
|
forceRedownload: false);
|
|
}
|
|
|
|
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
|
|
UpdateCheckResult checkResult,
|
|
IProgress<double>? progress = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(checkResult);
|
|
|
|
if (checkResult.PlondsPayload is not null)
|
|
{
|
|
ClearPendingUpdate();
|
|
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
|
}
|
|
|
|
return await DownloadFullInstallerAsync(
|
|
checkResult,
|
|
progress,
|
|
cancellationToken,
|
|
forceRedownload: true);
|
|
}
|
|
|
|
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var pending = GetPendingUpdate(state);
|
|
|
|
if (pending is null)
|
|
{
|
|
return new UpdateVerifyResult(false, false, null, null, "No pending update available.");
|
|
}
|
|
|
|
if (!File.Exists(pending.InstallerPath))
|
|
{
|
|
if (IsPendingDeltaUpdate())
|
|
{
|
|
var pdcUpdatePath = pending.InstallerPath;
|
|
var pdcFileMapPath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PlondsFileMapName);
|
|
var pdcSignaturePath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PlondsFileMapSignatureName);
|
|
if (File.Exists(pdcUpdatePath) && File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
|
|
{
|
|
return new UpdateVerifyResult(true, true, null, null, null);
|
|
}
|
|
|
|
return new UpdateVerifyResult(false, false, null, null, "PLONDS update payload is incomplete.");
|
|
}
|
|
|
|
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
|
|
}
|
|
|
|
if (IsPendingDeltaUpdate())
|
|
{
|
|
return new UpdateVerifyResult(true, true, null, null, null);
|
|
}
|
|
|
|
var expectedHash = pending.Sha256;
|
|
var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
|
|
|
|
if (string.IsNullOrEmpty(expectedHash))
|
|
{
|
|
return new UpdateVerifyResult(true, true, null, actualHash, null);
|
|
}
|
|
|
|
var hashMatched = string.Equals(
|
|
expectedHash?.Trim().ToLowerInvariant(),
|
|
actualHash?.Trim().ToLowerInvariant(),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
return new UpdateVerifyResult(
|
|
hashMatched,
|
|
hashMatched,
|
|
expectedHash,
|
|
actualHash,
|
|
hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}");
|
|
}
|
|
|
|
public async Task AutoCheckIfEnabledAsync(
|
|
Version currentVersion,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
|
|
try
|
|
{
|
|
// Always check for updates on startup (removed AutoCheckUpdates check)
|
|
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
|
|
if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PlondsPayload is null))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
|
|
|
|
// For "Silent Download" and "Silent Install" modes, automatically download the update
|
|
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Prefer delta update if available (smaller download, faster)
|
|
if (IsDeltaUpdateAvailable(result))
|
|
{
|
|
AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package.");
|
|
await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken);
|
|
}
|
|
else if (result.PreferredAsset is not null)
|
|
{
|
|
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
|
|
}
|
|
}
|
|
// For "Manual" mode, just check but don't download
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", "Automatic update check failed.", ex);
|
|
}
|
|
}
|
|
|
|
public UpdateInstallerLaunchResult LaunchPendingInstallerNow()
|
|
{
|
|
if (IsPendingDeltaUpdate())
|
|
{
|
|
var launchResult = LaunchLauncherForApplyUpdate();
|
|
return launchResult
|
|
? new UpdateInstallerLaunchResult(true, false, null)
|
|
: new UpdateInstallerLaunchResult(false, false, "Failed to launch updater for incremental update.");
|
|
}
|
|
|
|
return LaunchPendingInstaller(silent: false, exitApplicationAfterLaunch: true);
|
|
}
|
|
|
|
public bool TryApplyPendingUpdateOnExit()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
if (!string.Equals(
|
|
UpdateSettingsValues.NormalizeMode(state.UpdateMode),
|
|
UpdateSettingsValues.ModeSilentOnExit,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// For delta updates, launch the Launcher with apply-update command so it can
|
|
// apply the update immediately with a progress UI, matching the full installer experience.
|
|
if (IsPendingDeltaUpdate())
|
|
{
|
|
AppLogger.Info("UpdateWorkflow", "Delta update pending. Launching Launcher to apply update with progress UI.");
|
|
var launchResult = LaunchLauncherForApplyUpdate();
|
|
if (launchResult)
|
|
{
|
|
ClearPendingUpdate();
|
|
}
|
|
return launchResult;
|
|
}
|
|
|
|
var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false);
|
|
if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage))
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", $"Silent update on exit failed: {result.ErrorMessage}");
|
|
}
|
|
|
|
return result.Success;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Launches the Launcher process with the apply-update command to apply a pending delta update
|
|
/// with a progress UI, providing an experience similar to a full installer.
|
|
/// </summary>
|
|
public bool LaunchLauncherForApplyUpdate()
|
|
{
|
|
try
|
|
{
|
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
|
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", "Launcher executable not found. Falling back to next-startup apply.");
|
|
return false;
|
|
}
|
|
|
|
var launcherRoot = Path.GetDirectoryName(launcherPath)!;
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = launcherPath,
|
|
Arguments = $"apply-update --app-root \"{launcherRoot}\" --launch-source apply-update",
|
|
UseShellExecute = false,
|
|
WorkingDirectory = launcherRoot
|
|
};
|
|
|
|
Process.Start(startInfo);
|
|
AppLogger.Info("UpdateWorkflow", $"Launched Launcher for apply-update: {launcherPath}");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLogger.Warn("UpdateWorkflow", $"Failed to launch Launcher for apply-update: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void ClearPendingUpdate()
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
SaveState(state with
|
|
{
|
|
PendingUpdateInstallerPath = null,
|
|
PendingUpdateVersion = null,
|
|
PendingUpdatePublishedAtUtcMs = null,
|
|
PendingUpdateSha256 = null
|
|
});
|
|
}
|
|
|
|
private UpdateInstallerLaunchResult LaunchPendingInstaller(bool silent, bool exitApplicationAfterLaunch)
|
|
{
|
|
var state = _settingsFacade.Update.Get();
|
|
var pending = GetPendingUpdate(state);
|
|
if (pending is null)
|
|
{
|
|
return new UpdateInstallerLaunchResult(false, false, "No pending installer is available.");
|
|
}
|
|
|
|
try
|
|
{
|
|
AppLogger.Info("UpdateWorkflow", "Launching pending full installer with elevation reason 'full_update_apply'.");
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = pending.InstallerPath,
|
|
WorkingDirectory = Path.GetDirectoryName(pending.InstallerPath) ?? _updatesDirectory,
|
|
UseShellExecute = true,
|
|
Verb = OperatingSystem.IsWindows() ? "runas" : string.Empty,
|
|
Arguments = silent ? "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" : string.Empty
|
|
};
|
|
|
|
Process.Start(startInfo);
|
|
ClearPendingUpdate();
|
|
|
|
if (exitApplicationAfterLaunch)
|
|
{
|
|
App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
|
Source: "Update",
|
|
Reason: silent
|
|
? "Silent installer launched."
|
|
: "Installer launched from update page."));
|
|
}
|
|
|
|
return new UpdateInstallerLaunchResult(true, false, null);
|
|
}
|
|
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
|
{
|
|
return new UpdateInstallerLaunchResult(false, true, ex.Message);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new UpdateInstallerLaunchResult(false, false, ex.Message);
|
|
}
|
|
}
|
|
|
|
private UpdatePendingInfo? GetPendingUpdate(UpdateSettingsState state)
|
|
{
|
|
var installerPath = state.PendingUpdateInstallerPath?.Trim();
|
|
if (string.IsNullOrWhiteSpace(installerPath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!File.Exists(installerPath))
|
|
{
|
|
ClearPendingUpdate();
|
|
return null;
|
|
}
|
|
|
|
DateTimeOffset? publishedAt = state.PendingUpdatePublishedAtUtcMs is > 0
|
|
? DateTimeOffset.FromUnixTimeMilliseconds(state.PendingUpdatePublishedAtUtcMs.Value)
|
|
: null;
|
|
|
|
return new UpdatePendingInfo(
|
|
installerPath,
|
|
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
|
|
publishedAt,
|
|
state.PendingUpdateSha256);
|
|
}
|
|
|
|
private void SaveState(UpdateSettingsState state)
|
|
{
|
|
_settingsFacade.Update.Save(state);
|
|
}
|
|
|
|
private static string SanitizeFileName(string? fileName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
return FormattableString.Invariant($"LanMountainDesktop-update-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.exe");
|
|
}
|
|
|
|
var invalid = Path.GetInvalidFileNameChars();
|
|
Span<char> buffer = stackalloc char[fileName.Length];
|
|
var index = 0;
|
|
foreach (var ch in fileName)
|
|
{
|
|
buffer[index++] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch;
|
|
}
|
|
|
|
return new string(buffer[..index]);
|
|
}
|
|
}
|