mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
refactor update backend to host-managed PDC pipeline
This commit is contained in:
@@ -34,7 +34,17 @@ public sealed record UpdateCheckResult(
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage,
|
||||
bool ForceMode = false);
|
||||
bool ForceMode = false,
|
||||
PdcUpdatePayload? PdcPayload = null);
|
||||
|
||||
public sealed record PdcUpdatePayload(
|
||||
string DistributionId,
|
||||
string ChannelId,
|
||||
string SubChannel,
|
||||
string? FileMapJson,
|
||||
string? FileMapSignature,
|
||||
string? FileMapJsonUrl,
|
||||
string? FileMapSignatureUrl);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
|
||||
@@ -148,7 +148,8 @@ public sealed class PdcReleaseUpdateService : IDisposable
|
||||
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var assets = ResolveAssets(distributionNode);
|
||||
if (assets.Count == 0)
|
||||
var pdcPayload = ResolvePdcPayload(distributionNode, distributionId, channelId, subChannel);
|
||||
if (assets.Count == 0 && !HasPdcPayload(pdcPayload))
|
||||
{
|
||||
return new UpdateCheckResult(
|
||||
Success: false,
|
||||
@@ -168,6 +169,7 @@ public sealed class PdcReleaseUpdateService : IDisposable
|
||||
IsDraft: false,
|
||||
PublishedAt: DateTimeOffset.UtcNow,
|
||||
Assets: assets);
|
||||
var preferredAsset = SelectPreferredInstallerAsset(assets);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
@@ -175,9 +177,10 @@ public sealed class PdcReleaseUpdateService : IDisposable
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: null,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce);
|
||||
ForceMode: isForce,
|
||||
PdcPayload: pdcPayload);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -289,6 +292,119 @@ public sealed class PdcReleaseUpdateService : IDisposable
|
||||
return assets;
|
||||
}
|
||||
|
||||
private static PdcUpdatePayload ResolvePdcPayload(
|
||||
JsonElement distributionNode,
|
||||
string distributionId,
|
||||
string channelId,
|
||||
string subChannel)
|
||||
{
|
||||
var fileMapJson = ReadString(distributionNode, "fileMapJson");
|
||||
var fileMapSignature = ReadString(distributionNode, "fileMapSignature");
|
||||
var fileMapJsonUrl = ReadString(distributionNode, "fileMapJsonUrl")
|
||||
?? ReadString(distributionNode, "fileMapUrl")
|
||||
?? ReadString(distributionNode, "manifestUrl");
|
||||
var fileMapSignatureUrl = ReadString(distributionNode, "fileMapSignatureUrl")
|
||||
?? ReadString(distributionNode, "signatureUrl");
|
||||
return new PdcUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: channelId,
|
||||
SubChannel: subChannel,
|
||||
FileMapJson: fileMapJson,
|
||||
FileMapSignature: fileMapSignature,
|
||||
FileMapJsonUrl: fileMapJsonUrl,
|
||||
FileMapSignatureUrl: fileMapSignatureUrl);
|
||||
}
|
||||
|
||||
private static bool HasPdcPayload(PdcUpdatePayload payload)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(payload.FileMapJson)
|
||||
|| !string.IsNullOrWhiteSpace(payload.FileMapJsonUrl);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||
{
|
||||
if (assets is null || assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var archToken = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.X86 => "x86",
|
||||
_ => "x64"
|
||||
};
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".exe", ".msi", archToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".deb", ".rpm", "x64")))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
var archToken = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64";
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".dmg", ".pkg", archToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ScoreInstallerAsset(string name, string ext1, string ext2, string archToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
if (name.EndsWith(ext1, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 200;
|
||||
}
|
||||
else if (name.EndsWith(ext2, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 160;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (name.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("installer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 50;
|
||||
}
|
||||
|
||||
if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if (name.Contains("portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
|
||||
{
|
||||
if (metadataNode.ValueKind != JsonValueKind.Object ||
|
||||
|
||||
@@ -356,6 +356,7 @@ public interface IUpdateSettingsService
|
||||
void Save(UpdateSettingsState state);
|
||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
|
||||
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
|
||||
@@ -842,6 +842,18 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = isForce
|
||||
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return result.Success ? result.PdcPayload : null;
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
|
||||
@@ -11,7 +11,10 @@ public static class UpdateSettingsValues
|
||||
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
||||
public const string ModeSilentOnExit = "silent_on_exit";
|
||||
|
||||
public const string DownloadSourcePdc = "pdc";
|
||||
// NOTE: keep constant name for compatibility with existing call sites.
|
||||
public const string DownloadSourcePdc = "stcn";
|
||||
public const string DownloadSourceStcn = DownloadSourcePdc;
|
||||
public const string LegacyDownloadSourcePdc = "pdc";
|
||||
public const string DownloadSourceGitHub = "github";
|
||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||
|
||||
@@ -52,6 +55,11 @@ public static class UpdateSettingsValues
|
||||
|
||||
public static string NormalizeDownloadSource(string? value)
|
||||
{
|
||||
if (string.Equals(value, LegacyDownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceStcn;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourcePdc;
|
||||
@@ -67,8 +75,8 @@ public static class UpdateSettingsValues
|
||||
return DownloadSourceGitHub;
|
||||
}
|
||||
|
||||
// Default to PDC. Runtime will fallback to GitHub if PDC is unavailable.
|
||||
return DownloadSourcePdc;
|
||||
// Default to STCN(PDC/S3). Runtime will fallback to GitHub if STCN is unavailable.
|
||||
return DownloadSourceStcn;
|
||||
}
|
||||
|
||||
public static int NormalizeDownloadThreads(int value)
|
||||
|
||||
@@ -5,7 +5,11 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
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;
|
||||
@@ -53,9 +57,20 @@ public sealed class UpdateWorkflowService
|
||||
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 PdcFileMapName = "pdc-filemap.json";
|
||||
private const string PdcFileMapSignatureName = "pdc-filemap.sig";
|
||||
private const string PdcUpdateStateName = "pdc-update.json";
|
||||
|
||||
private static readonly HttpClient PdcHttpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
private static readonly ResumableDownloadService PdcDownloadService = new(PdcHttpClient);
|
||||
|
||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
@@ -81,6 +96,11 @@ public sealed class UpdateWorkflowService
|
||||
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>
|
||||
@@ -94,6 +114,16 @@ public sealed class UpdateWorkflowService
|
||||
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
|
||||
}
|
||||
|
||||
public static bool IsDeltaUpdateAvailable(UpdateCheckResult checkResult)
|
||||
{
|
||||
if (checkResult.PdcPayload 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>
|
||||
@@ -104,12 +134,24 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null)
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
||||
}
|
||||
|
||||
if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
|
||||
if (checkResult.PdcPayload is null && checkResult.Release is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No update payload is available for delta download.");
|
||||
}
|
||||
|
||||
if (checkResult.PdcPayload is not null)
|
||||
{
|
||||
return await DownloadPdcDeltaUpdateAsync(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.");
|
||||
}
|
||||
@@ -189,9 +231,9 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
@@ -201,6 +243,163 @@ public sealed class UpdateWorkflowService
|
||||
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
|
||||
}
|
||||
|
||||
private async Task<UpdateDownloadResult> DownloadPdcDeltaUpdateAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = checkResult.PdcPayload;
|
||||
if (payload is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "PDC payload is missing.");
|
||||
}
|
||||
|
||||
var incomingDir = GetLauncherIncomingDirectory();
|
||||
var objectsDir = GetLauncherIncomingObjectsDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(incomingDir);
|
||||
Directory.CreateDirectory(objectsDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var downloadThreads = Math.Max(1, state.UpdateDownloadThreads);
|
||||
var fileMapPath = Path.Combine(incomingDir, PdcFileMapName);
|
||||
var signaturePath = Path.Combine(incomingDir, PdcFileMapSignatureName);
|
||||
var updateStatePath = Path.Combine(incomingDir, PdcUpdateStateName);
|
||||
|
||||
var fileMapJson = await EnsurePdcTextResourceAsync(
|
||||
payload.FileMapJson,
|
||||
payload.FileMapJsonUrl,
|
||||
fileMapPath,
|
||||
cancellationToken);
|
||||
|
||||
var fileMapSignature = await EnsurePdcTextResourceAsync(
|
||||
payload.FileMapSignature,
|
||||
payload.FileMapSignatureUrl,
|
||||
signaturePath,
|
||||
cancellationToken);
|
||||
|
||||
var downloadEntries = ParsePdcDownloadEntries(fileMapJson);
|
||||
if (downloadEntries.Count == 0)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "PDC file map does not contain downloadable objects.");
|
||||
}
|
||||
|
||||
var expectedObjectCount = downloadEntries.Count;
|
||||
var completedItems = 2;
|
||||
progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2));
|
||||
|
||||
var objectResults = new List<PdcDownloadedObjectInfo>(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 destinationPath = GetPdcObjectDestinationPath(objectsDir, entry.ObjectHashHex);
|
||||
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
}
|
||||
|
||||
if (File.Exists(destinationPath))
|
||||
{
|
||||
var existingHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken);
|
||||
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
objectResults.Add(new PdcDownloadedObjectInfo(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 PdcDownloadService.DownloadAsync(
|
||||
entry.DownloadUrl,
|
||||
destinationPath,
|
||||
downloadOptions,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"Failed to download PDC object {entry.RelativePath}: {downloadResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
var actualHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(actualHash) &&
|
||||
!string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"PDC object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
|
||||
}
|
||||
|
||||
objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
}
|
||||
|
||||
var updateState = new PdcUpdateState(
|
||||
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", $"PDC 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)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", "Failed to download PDC incremental payload.", ex);
|
||||
return new UpdateDownloadResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions UpdateJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the pending update is managed by Launcher incoming payload.
|
||||
/// </summary>
|
||||
@@ -213,11 +412,261 @@ public sealed class UpdateWorkflowService
|
||||
return false;
|
||||
}
|
||||
|
||||
// Incoming payload updates are identified by files.json or incoming directory path.
|
||||
// Incoming payload updates are identified by the local manifest or incoming directory path.
|
||||
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.EndsWith(PdcUpdateStateName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.EndsWith(PdcFileMapName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.EndsWith(PdcFileMapSignatureName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetPdcObjectDestinationPath(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> EnsurePdcTextResourceAsync(
|
||||
string? inlineContent,
|
||||
string? sourceUrl,
|
||||
string destinationPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(inlineContent))
|
||||
{
|
||||
await File.WriteAllTextAsync(destinationPath, inlineContent, cancellationToken);
|
||||
return inlineContent;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceUrl))
|
||||
{
|
||||
throw new InvalidOperationException("PDC payload does not contain a file map source.");
|
||||
}
|
||||
|
||||
var downloadResult = await PdcDownloadService.DownloadAsync(
|
||||
sourceUrl,
|
||||
destinationPath,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to download PDC file map resource: {downloadResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PdcDownloadEntry> ParsePdcDownloadEntries(string fileMapJson)
|
||||
{
|
||||
var entries = new List<PdcDownloadEntry>();
|
||||
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) ||
|
||||
componentsNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return entries;
|
||||
}
|
||||
|
||||
foreach (var component in componentsNode.EnumerateObject())
|
||||
{
|
||||
if (component.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode) ||
|
||||
filesNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var fileEntry in filesNode.EnumerateObject())
|
||||
{
|
||||
if (fileEntry.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadUrl = ReadStringIgnoreCase(fileEntry.Value, "archivedownloadurl")
|
||||
?? ReadStringIgnoreCase(fileEntry.Value, "downloadurl")
|
||||
?? ReadStringIgnoreCase(fileEntry.Value, "url");
|
||||
var hashBytes = ReadByteArrayIgnoreCase(fileEntry.Value, "archivesha512")
|
||||
?? ReadByteArrayIgnoreCase(fileEntry.Value, "filesha512");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(downloadUrl) || hashBytes is null || hashBytes.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
entries.Add(new PdcDownloadEntry(
|
||||
component.Name,
|
||||
fileEntry.Name,
|
||||
downloadUrl,
|
||||
hashHex));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static async Task<string?> ComputeFileSha512HexAsync(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 SHA512.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
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 PdcDownloadEntry(
|
||||
string ComponentId,
|
||||
string RelativePath,
|
||||
string DownloadUrl,
|
||||
string ObjectHashHex);
|
||||
|
||||
private sealed record PdcDownloadedObjectInfo(
|
||||
string ComponentId,
|
||||
string RelativePath,
|
||||
string SourceUrl,
|
||||
string ObjectHashHex,
|
||||
string LocalPath);
|
||||
|
||||
private sealed record PdcUpdateState(
|
||||
string VersionText,
|
||||
string DistributionId,
|
||||
string ChannelId,
|
||||
string SubChannel,
|
||||
string FileMapPath,
|
||||
string FileMapSignaturePath,
|
||||
string ObjectsDirectory,
|
||||
DateTimeOffset DownloadedAtUtc,
|
||||
string FileMapJson,
|
||||
string FileMapSignature,
|
||||
IReadOnlyList<PdcDownloadedObjectInfo> Objects);
|
||||
|
||||
private static bool TryResolveDeltaAssets(
|
||||
IReadOnlyList<GitHubReleaseAsset> assets,
|
||||
out GitHubReleaseAsset manifestAsset,
|
||||
@@ -327,6 +776,11 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (checkResult.PdcPayload is not null)
|
||||
{
|
||||
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.");
|
||||
@@ -365,9 +819,9 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
@@ -383,6 +837,12 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (checkResult.PdcPayload is not null)
|
||||
{
|
||||
ClearPendingUpdate();
|
||||
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.");
|
||||
@@ -426,9 +886,9 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
@@ -449,9 +909,27 @@ public sealed class UpdateWorkflowService
|
||||
|
||||
if (!File.Exists(pending.InstallerPath))
|
||||
{
|
||||
if (IsPendingDeltaUpdate())
|
||||
{
|
||||
var pdcUpdatePath = pending.InstallerPath;
|
||||
var pdcFileMapPath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PdcFileMapName);
|
||||
var pdcSignaturePath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PdcFileMapSignatureName);
|
||||
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, "PDC 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);
|
||||
|
||||
@@ -483,7 +961,7 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
// 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)
|
||||
if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PdcPayload is null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -495,7 +973,7 @@ public sealed class UpdateWorkflowService
|
||||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Prefer delta update if available (smaller download, faster)
|
||||
if (IsDeltaUpdateAvailable(result.Release))
|
||||
if (IsDeltaUpdateAvailable(result))
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package.");
|
||||
await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken);
|
||||
@@ -519,6 +997,14 @@ public sealed class UpdateWorkflowService
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user