feat.Penguin Logistics Online Network Distribution System

This commit is contained in:
lincube
2026-04-20 23:28:11 +08:00
parent 3f927c41c8
commit a31ae3cd58
47 changed files with 2446 additions and 822 deletions

View File

@@ -35,9 +35,9 @@ public sealed record UpdateCheckResult(
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage,
bool ForceMode = false,
PdcUpdatePayload? PdcPayload = null);
PlondsUpdatePayload? PlondsPayload = null);
public sealed record PdcUpdatePayload(
public sealed record PlondsUpdatePayload(
string DistributionId,
string ChannelId,
string SubChannel,

View File

@@ -11,15 +11,17 @@ using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
/// <summary>
/// Best-effort PDC client that maps PDC responses to the existing update result model.
/// This keeps launcher update contracts stable while allowing a gradual migration.
/// Thin PLONDS client used by the host app.
/// The host keeps responsibility for checking and downloading updates; Launcher only applies staged payloads.
/// </summary>
public sealed class PdcReleaseUpdateService : IDisposable
public sealed class PlondsReleaseUpdateService : IDisposable
{
private const string DefaultApiBasePath = "/api/plonds/v1";
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public PdcReleaseUpdateService(HttpClient? httpClient = null)
public PlondsReleaseUpdateService(HttpClient? httpClient = null)
{
if (httpClient is null)
{
@@ -79,25 +81,40 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC endpoint is not configured.",
ErrorMessage: "PLONDS endpoint is not configured.",
ForceMode: isForce);
}
try
{
var metadataUrl = BuildUri(endpoint, "api/v1/public/distributions/metadata");
var metadata = await GetContentNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var apiBasePath = ResolveApiBasePath();
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
var metadata = await GetJsonNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var channelId = ResolveChannelId(metadata, includePrerelease);
if (string.IsNullOrWhiteSpace(channelId))
{
channelId = includePrerelease ? "preview" : "stable";
}
var latestUrl = BuildUri(
var channelId = ResolveChannelId(includePrerelease);
var platform = ResolvePlatform();
var latestUrl = BuildApiUrl(
endpoint,
$"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
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))
{
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: normalizedCurrentVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: null,
ForceMode: isForce);
}
var latestVersionText = ReadString(latestNode, "version") ?? "-";
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
@@ -109,7 +126,7 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution version is invalid.",
ErrorMessage: "PLONDS latest distribution version is invalid.",
ForceMode: isForce);
}
@@ -123,7 +140,7 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution id is missing.",
ErrorMessage: "PLONDS latest distribution id is missing.",
ForceMode: isForce);
}
@@ -141,15 +158,15 @@ public sealed class PdcReleaseUpdateService : IDisposable
ForceMode: false);
}
var subChannel = ResolveSubChannel();
var distributionUrl = BuildUri(
var distributionUrl = BuildApiUrl(
endpoint,
$"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}");
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
apiBasePath,
$"distributions/{Uri.EscapeDataString(distributionId)}");
var distributionNode = await GetJsonNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
var assets = ResolveAssets(distributionNode);
var pdcPayload = ResolvePdcPayload(distributionNode, distributionId, channelId, subChannel);
if (assets.Count == 0 && !HasPdcPayload(pdcPayload))
var assets = ResolveInstallerAssets(distributionNode);
var payload = ResolvePlondsPayload(distributionNode, distributionId, channelId, platform);
if (assets.Count == 0 && !HasPlondsPayload(payload))
{
return new UpdateCheckResult(
Success: false,
@@ -158,18 +175,18 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC distribution response does not expose downloadable update assets.",
ErrorMessage: "PLONDS distribution response does not expose downloadable update assets.",
ForceMode: isForce);
}
var publishedAt = ParsePublishedAt(distributionNode) ?? DateTimeOffset.UtcNow;
var release = new GitHubReleaseInfo(
TagName: $"v{latestVersionText}",
Name: $"PDC Distribution {latestVersionText}",
Name: $"PLONDS Distribution {latestVersionText}",
IsPrerelease: includePrerelease,
IsDraft: false,
PublishedAt: DateTimeOffset.UtcNow,
PublishedAt: publishedAt,
Assets: assets);
var preferredAsset = SelectPreferredInstallerAsset(assets);
return new UpdateCheckResult(
Success: true,
@@ -177,10 +194,10 @@ public sealed class PdcReleaseUpdateService : IDisposable
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
PreferredAsset: SelectPreferredInstallerAsset(assets),
ErrorMessage: null,
ForceMode: isForce,
PdcPayload: pdcPayload);
PlondsPayload: payload);
}
catch (OperationCanceledException)
{
@@ -195,12 +212,12 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: $"PDC request failed: {ex.Message}",
ErrorMessage: $"PLONDS request failed: {ex.Message}",
ForceMode: isForce);
}
}
private async Task<JsonElement> GetContentNodeAsync(string url, CancellationToken cancellationToken)
private async Task<JsonElement> GetJsonNodeAsync(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var token = ResolveToken();
@@ -227,15 +244,39 @@ public sealed class PdcReleaseUpdateService : IDisposable
return root.Clone();
}
private static IReadOnlyList<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode)
{
var assets = new List<GitHubReleaseAsset>();
if (distributionNode.ValueKind != JsonValueKind.Object)
if (TryGetPropertyIgnoreCase(distributionNode, "installerMirrors", out var installersNode) &&
installersNode.ValueKind == JsonValueKind.Array)
{
foreach (var installerNode in installersNode.EnumerateArray())
{
if (installerNode.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = ReadString(installerNode, "name");
var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
}
var size = ReadInt64(installerNode, "size") ?? 0L;
var sha256 = ReadString(installerNode, "sha256");
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
}
}
if (assets.Count > 0)
{
return assets;
}
if (distributionNode.TryGetProperty("assets", out var assetsNode) &&
if (TryGetPropertyIgnoreCase(distributionNode, "assets", out var assetsNode) &&
assetsNode.ValueKind == JsonValueKind.Array)
{
foreach (var assetNode in assetsNode.EnumerateArray())
@@ -246,9 +287,9 @@ public sealed class PdcReleaseUpdateService : IDisposable
}
var name = ReadString(assetNode, "name");
var url = ReadString(assetNode, "url") ??
ReadString(assetNode, "downloadUrl") ??
ReadString(assetNode, "browserDownloadUrl");
var url = ReadString(assetNode, "url")
?? ReadString(assetNode, "downloadUrl")
?? ReadString(assetNode, "browserDownloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
@@ -260,43 +301,14 @@ public sealed class PdcReleaseUpdateService : IDisposable
}
}
if (assets.Count > 0)
{
return assets;
}
// Field-level fallback for service-side URL projection.
var manifestUrl = ReadString(distributionNode, "manifestUrl")
?? ReadString(distributionNode, "fileMapUrl");
var signatureUrl = ReadString(distributionNode, "signatureUrl")
?? ReadString(distributionNode, "fileMapSignatureUrl");
var archiveUrl = ReadString(distributionNode, "archiveUrl")
?? ReadString(distributionNode, "updateArchiveUrl")
?? ReadString(distributionNode, "payloadUrl");
if (!string.IsNullOrWhiteSpace(manifestUrl))
{
assets.Add(new GitHubReleaseAsset("files.json", manifestUrl, 0, null));
}
if (!string.IsNullOrWhiteSpace(signatureUrl))
{
assets.Add(new GitHubReleaseAsset("files.json.sig", signatureUrl, 0, null));
}
if (!string.IsNullOrWhiteSpace(archiveUrl))
{
assets.Add(new GitHubReleaseAsset("update.zip", archiveUrl, 0, null));
}
return assets;
}
private static PdcUpdatePayload ResolvePdcPayload(
private static PlondsUpdatePayload ResolvePlondsPayload(
JsonElement distributionNode,
string distributionId,
string channelId,
string subChannel)
string platform)
{
var fileMapJson = ReadString(distributionNode, "fileMapJson");
var fileMapSignature = ReadString(distributionNode, "fileMapSignature");
@@ -305,17 +317,18 @@ public sealed class PdcReleaseUpdateService : IDisposable
?? ReadString(distributionNode, "manifestUrl");
var fileMapSignatureUrl = ReadString(distributionNode, "fileMapSignatureUrl")
?? ReadString(distributionNode, "signatureUrl");
return new PdcUpdatePayload(
return new PlondsUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: subChannel,
SubChannel: platform,
FileMapJson: fileMapJson,
FileMapSignature: fileMapSignature,
FileMapJsonUrl: fileMapJsonUrl,
FileMapSignatureUrl: fileMapSignatureUrl);
}
private static bool HasPdcPayload(PdcUpdatePayload payload)
private static bool HasPlondsPayload(PlondsUpdatePayload payload)
{
return !string.IsNullOrWhiteSpace(payload.FileMapJson)
|| !string.IsNullOrWhiteSpace(payload.FileMapJsonUrl);
@@ -336,6 +349,7 @@ public sealed class PdcReleaseUpdateService : IDisposable
Architecture.X86 => "x86",
_ => "x64"
};
return assets
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".exe", ".msi", archToken)))
.OrderByDescending(x => x.Score)
@@ -405,59 +419,15 @@ public sealed class PdcReleaseUpdateService : IDisposable
return score;
}
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
private static string ResolveChannelId(bool includePrerelease)
{
if (metadataNode.ValueKind != JsonValueKind.Object ||
!metadataNode.TryGetProperty("channels", out var channelsNode))
{
return includePrerelease ? "preview" : "stable";
}
var defaultChannelId = ReadString(metadataNode, "defaultChannelId") ?? string.Empty;
if (channelsNode.ValueKind != JsonValueKind.Object)
{
return defaultChannelId;
}
string? matchedPreview = null;
string? matchedStable = null;
foreach (var channel in channelsNode.EnumerateObject())
{
var name = ReadString(channel.Value, "name") ?? channel.Name;
if (string.IsNullOrWhiteSpace(matchedPreview) &&
(name.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
name.Contains("beta", StringComparison.OrdinalIgnoreCase) ||
name.Contains("dev", StringComparison.OrdinalIgnoreCase)))
{
matchedPreview = channel.Name;
}
if (string.IsNullOrWhiteSpace(matchedStable) &&
(name.Contains("stable", StringComparison.OrdinalIgnoreCase) ||
name.Contains("release", StringComparison.OrdinalIgnoreCase)))
{
matchedStable = channel.Name;
}
}
if (includePrerelease)
{
return matchedPreview ?? defaultChannelId ?? "preview";
}
return matchedStable ?? defaultChannelId ?? "stable";
return includePrerelease
? UpdateSettingsValues.ChannelPreview
: UpdateSettingsValues.ChannelStable;
}
private static string ResolveSubChannel()
private static string ResolvePlatform()
{
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_SUBCHANNEL")
?? Environment.GetEnvironmentVariable("PDC_SUBCHANNEL");
if (!string.IsNullOrWhiteSpace(configured))
{
return configured.Trim();
}
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
@@ -474,43 +444,58 @@ public sealed class PdcReleaseUpdateService : IDisposable
_ => "x64"
};
return $"{os}_{arch}_release_folderClassic";
return $"{os}-{arch}";
}
private static string? ResolveEndpoint()
{
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT")
?? Environment.GetEnvironmentVariable("PDC_ENDPOINT");
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_ENDPOINT")
?? Environment.GetEnvironmentVariable("PLONDS_ENDPOINT");
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
}
private static string? ResolveToken()
{
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN")
?? Environment.GetEnvironmentVariable("PDC_TOKEN");
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_TOKEN")
?? Environment.GetEnvironmentVariable("PLONDS_TOKEN");
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
}
private static string BuildUri(string endpoint, string relativePath)
private static string ResolveApiBasePath()
{
return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}";
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_API_BASE_PATH")
?? Environment.GetEnvironmentVariable("PLONDS_API_BASE_PATH");
if (string.IsNullOrWhiteSpace(configured))
{
return DefaultApiBasePath;
}
var normalized = configured.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal) ? normalized : "/" + normalized;
}
private static string BuildApiUrl(string endpoint, string apiBasePath, string relativePath)
{
return $"{endpoint.TrimEnd('/')}/{apiBasePath.Trim('/').TrimEnd('/')}/{relativePath.TrimStart('/')}";
}
private static string? ReadString(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String
? value.GetString()
: value.ToString();
: value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
? null
: value.ToString();
}
private static long? ReadInt64(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
{
return null;
}
@@ -526,6 +511,37 @@ public sealed class PdcReleaseUpdateService : IDisposable
: null;
}
private static DateTimeOffset? ParsePublishedAt(JsonElement node)
{
var text = ReadString(node, "publishedAt");
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value)
? value
: null;
}
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 bool TryParseVersion(string? value, out Version? version)
{
version = null;

View File

@@ -356,7 +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<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,

View File

@@ -752,7 +752,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
{
private readonly ISettingsService _settingsService;
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new();
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
public UpdateSettingsService(ISettingsService settingsService)
{
@@ -842,16 +842,16 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public async Task<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
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;
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return result.Success ? result.PlondsPayload : null;
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
@@ -891,7 +891,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
_pdcReleaseUpdateService.Dispose();
_plondsReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
@@ -901,20 +901,20 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
CancellationToken cancellationToken)
{
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
var pdcResult = isForce
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
var plondsResult = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (pdcResult.Success)
if (plondsResult.Success)
{
return pdcResult;
return plondsResult;
}
AppLogger.Warn(
"UpdateSettings",
$"PDC update check failed and will fallback to GitHub. Error: {pdcResult.ErrorMessage}");
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
}
return isForce
@@ -1271,14 +1271,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppVersionText()
{
// 优先从环境变量读取(Launcher 传递)
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
if (!string.IsNullOrWhiteSpace(envVersion))
{
return envVersion;
}
// 回退:从程序集读取
// Fallback: read from application assembly.
var assembly = typeof(App).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
@@ -1318,14 +1318,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppCodenameText()
{
// 优先从环境变量读取(Launcher 传递)
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
if (!string.IsNullOrWhiteSpace(envCodename))
{
return envCodename;
}
// 回退:使用默认开发代号
// Fallback: use default codename.
return DefaultCodename;
}

View File

@@ -12,9 +12,11 @@ public static class UpdateSettingsValues
public const string ModeSilentOnExit = "silent_on_exit";
// 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 DownloadSourcePlonds = "stcn";
public const string DownloadSourcePdc = DownloadSourcePlonds;
public const string DownloadSourceStcn = DownloadSourcePlonds;
public const string LegacyDownloadSourcePlonds = "pdc";
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
@@ -55,14 +57,14 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
if (string.Equals(value, LegacyDownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceStcn;
}
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePdc;
return DownloadSourcePlonds;
}
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
@@ -75,7 +77,7 @@ public static class UpdateSettingsValues
return DownloadSourceGitHub;
}
// Default to STCN(PDC/S3). Runtime will fallback to GitHub if STCN is unavailable.
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
return DownloadSourceStcn;
}

View File

@@ -61,16 +61,16 @@ public sealed class UpdateWorkflowService
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 const string PlondsFileMapName = "plonds-filemap.json";
private const string PlondsFileMapSignatureName = "plonds-filemap.sig";
private const string PlondsUpdateStateName = "plonds-update.json";
private static readonly HttpClient PdcHttpClient = new()
private static readonly HttpClient PlondsHttpClient = new()
{
Timeout = TimeSpan.FromMinutes(5)
};
private static readonly ResumableDownloadService PdcDownloadService = new(PdcHttpClient);
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{
@@ -116,7 +116,7 @@ public sealed class UpdateWorkflowService
public static bool IsDeltaUpdateAvailable(UpdateCheckResult checkResult)
{
if (checkResult.PdcPayload is not null)
if (checkResult.PlondsPayload is not null)
{
return true;
}
@@ -139,14 +139,14 @@ public sealed class UpdateWorkflowService
return new UpdateDownloadResult(false, null, "No update available for delta download.");
}
if (checkResult.PdcPayload is null && checkResult.Release is null)
if (checkResult.PlondsPayload 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)
if (checkResult.PlondsPayload is not null)
{
return await DownloadPdcDeltaUpdateAsync(checkResult, progress, cancellationToken);
return await DownloadPlondsDeltaUpdateAsync(checkResult, progress, cancellationToken);
}
var release = checkResult.Release;
@@ -243,15 +243,15 @@ public sealed class UpdateWorkflowService
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
}
private async Task<UpdateDownloadResult> DownloadPdcDeltaUpdateAsync(
private async Task<UpdateDownloadResult> DownloadPlondsDeltaUpdateAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
var payload = checkResult.PdcPayload;
var payload = checkResult.PlondsPayload;
if (payload is null)
{
return new UpdateDownloadResult(false, null, "PDC payload is missing.");
return new UpdateDownloadResult(false, null, "PLONDS payload is missing.");
}
var incomingDir = GetLauncherIncomingDirectory();
@@ -271,33 +271,33 @@ public sealed class UpdateWorkflowService
{
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 fileMapPath = Path.Combine(incomingDir, PlondsFileMapName);
var signaturePath = Path.Combine(incomingDir, PlondsFileMapSignatureName);
var updateStatePath = Path.Combine(incomingDir, PlondsUpdateStateName);
var fileMapJson = await EnsurePdcTextResourceAsync(
var fileMapJson = await EnsurePlondsTextResourceAsync(
payload.FileMapJson,
payload.FileMapJsonUrl,
fileMapPath,
cancellationToken);
var fileMapSignature = await EnsurePdcTextResourceAsync(
var fileMapSignature = await EnsurePlondsTextResourceAsync(
payload.FileMapSignature,
payload.FileMapSignatureUrl,
signaturePath,
cancellationToken);
var downloadEntries = ParsePdcDownloadEntries(fileMapJson);
var downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
if (downloadEntries.Count == 0)
{
return new UpdateDownloadResult(false, null, "PDC file map does not contain downloadable objects.");
return new UpdateDownloadResult(false, null, "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 objectResults = new List<PdcDownloadedObjectInfo>(expectedObjectCount);
var objectResults = new List<PlondsDownloadedObjectInfo>(expectedObjectCount);
var objectTargets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var totalSteps = expectedObjectCount + 2;
@@ -310,7 +310,7 @@ public sealed class UpdateWorkflowService
continue;
}
var destinationPath = GetPdcObjectDestinationPath(objectsDir, entry.ObjectHashHex);
var destinationPath = GetPlondsObjectDestinationPath(objectsDir, entry.ObjectHashHex);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
{
@@ -319,10 +319,10 @@ public sealed class UpdateWorkflowService
if (File.Exists(destinationPath))
{
var existingHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken);
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++;
progress?.Report((double)completedItems / totalSteps);
continue;
@@ -330,7 +330,7 @@ public sealed class UpdateWorkflowService
}
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
var downloadResult = await PdcDownloadService.DownloadAsync(
var downloadResult = await PlondsDownloadService.DownloadAsync(
entry.DownloadUrl,
destinationPath,
downloadOptions,
@@ -339,22 +339,22 @@ public sealed class UpdateWorkflowService
if (!downloadResult.Success)
{
return new UpdateDownloadResult(false, null, $"Failed to download PDC object {entry.RelativePath}: {downloadResult.ErrorMessage}");
return new UpdateDownloadResult(false, null, $"Failed to download PLONDS object {entry.RelativePath}: {downloadResult.ErrorMessage}");
}
var actualHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken);
var actualHash = await ComputeFileSha256HexAsync(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}");
return new UpdateDownloadResult(false, null, $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
}
objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++;
progress?.Report((double)completedItems / totalSteps);
}
var updateState = new PdcUpdateState(
var updateState = new PlondsUpdateState(
checkResult.LatestVersionText,
payload.DistributionId,
payload.ChannelId,
@@ -381,7 +381,7 @@ public sealed class UpdateWorkflowService
});
progress?.Report(1d);
AppLogger.Info("UpdateWorkflow", $"PDC update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
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)
@@ -390,7 +390,7 @@ public sealed class UpdateWorkflowService
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", "Failed to download PDC incremental payload.", ex);
AppLogger.Warn("UpdateWorkflow", "Failed to download PLONDS incremental payload.", ex);
return new UpdateDownloadResult(false, null, ex.Message);
}
}
@@ -414,20 +414,20 @@ public sealed class UpdateWorkflowService
// 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.EndsWith(PlondsUpdateStateName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.EndsWith(PlondsFileMapName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.EndsWith(PlondsFileMapSignatureName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
}
private static string GetPdcObjectDestinationPath(string objectsDirectory, string objectHashHex)
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> EnsurePdcTextResourceAsync(
private static async Task<string> EnsurePlondsTextResourceAsync(
string? inlineContent,
string? sourceUrl,
string destinationPath,
@@ -441,25 +441,25 @@ public sealed class UpdateWorkflowService
if (string.IsNullOrWhiteSpace(sourceUrl))
{
throw new InvalidOperationException("PDC payload does not contain a file map source.");
throw new InvalidOperationException("PLONDS payload does not contain a file map source.");
}
var downloadResult = await PdcDownloadService.DownloadAsync(
var downloadResult = await PlondsDownloadService.DownloadAsync(
sourceUrl,
destinationPath,
cancellationToken: cancellationToken);
if (!downloadResult.Success)
{
throw new InvalidOperationException($"Failed to download PDC file map resource: {downloadResult.ErrorMessage}");
throw new InvalidOperationException($"Failed to download PLONDS file map resource: {downloadResult.ErrorMessage}");
}
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
}
private static IReadOnlyList<PdcDownloadEntry> ParsePdcDownloadEntries(string fileMapJson)
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
{
var entries = new List<PdcDownloadEntry>();
var entries = new List<PlondsDownloadEntry>();
if (string.IsNullOrWhiteSpace(fileMapJson))
{
return entries;
@@ -472,25 +472,56 @@ public sealed class UpdateWorkflowService
return entries;
}
if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode) ||
componentsNode.ValueKind != JsonValueKind.Object)
if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode))
{
return entries;
}
foreach (var component in componentsNode.EnumerateObject())
if (componentsNode.ValueKind == JsonValueKind.Object)
{
if (component.Value.ValueKind != JsonValueKind.Object)
foreach (var component in componentsNode.EnumerateObject())
{
continue;
}
if (component.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode) ||
filesNode.ValueKind != JsonValueKind.Object)
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())
{
continue;
}
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)
@@ -498,30 +529,82 @@ public sealed class UpdateWorkflowService
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)
if (TryCreateDownloadEntry(componentId, fileEntry.Name, fileEntry.Value, out var entry))
{
continue;
entries.Add(entry);
}
}
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
entries.Add(new PdcDownloadEntry(
component.Name,
fileEntry.Name,
downloadUrl,
hashHex));
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");
}
}
return entries;
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?> ComputeFileSha512HexAsync(string filePath, CancellationToken cancellationToken)
private static async Task<string?> ComputeFileSha256HexAsync(string filePath, CancellationToken cancellationToken)
{
if (!File.Exists(filePath))
{
@@ -529,10 +612,22 @@ public sealed class UpdateWorkflowService
}
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var hashBytes = await SHA512.HashDataAsync(stream, cancellationToken);
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 bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{
if (node.ValueKind == JsonValueKind.Object)
@@ -641,20 +736,20 @@ public sealed class UpdateWorkflowService
return true;
}
private sealed record PdcDownloadEntry(
private sealed record PlondsDownloadEntry(
string ComponentId,
string RelativePath,
string DownloadUrl,
string ObjectHashHex);
private sealed record PdcDownloadedObjectInfo(
private sealed record PlondsDownloadedObjectInfo(
string ComponentId,
string RelativePath,
string SourceUrl,
string ObjectHashHex,
string LocalPath);
private sealed record PdcUpdateState(
private sealed record PlondsUpdateState(
string VersionText,
string DistributionId,
string ChannelId,
@@ -665,7 +760,7 @@ public sealed class UpdateWorkflowService
DateTimeOffset DownloadedAtUtc,
string FileMapJson,
string FileMapSignature,
IReadOnlyList<PdcDownloadedObjectInfo> Objects);
IReadOnlyList<PlondsDownloadedObjectInfo> Objects);
private static bool TryResolveDeltaAssets(
IReadOnlyList<GitHubReleaseAsset> assets,
@@ -776,7 +871,7 @@ public sealed class UpdateWorkflowService
{
ArgumentNullException.ThrowIfNull(checkResult);
if (checkResult.PdcPayload is not null)
if (checkResult.PlondsPayload is not null)
{
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
}
@@ -837,7 +932,7 @@ public sealed class UpdateWorkflowService
{
ArgumentNullException.ThrowIfNull(checkResult);
if (checkResult.PdcPayload is not null)
if (checkResult.PlondsPayload is not null)
{
ClearPendingUpdate();
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
@@ -912,14 +1007,14 @@ public sealed class UpdateWorkflowService
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);
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, "PDC update payload is incomplete.");
return new UpdateVerifyResult(false, false, null, null, "PLONDS update payload is incomplete.");
}
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
@@ -961,7 +1056,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 && result.PdcPayload is null))
if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PlondsPayload is null))
{
return;
}