Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/c#/GeneralUpdate.Core/Configuration/PackageType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace GeneralUpdate.Core.Configuration;

/// <summary>
/// Defines the type of an update package.
/// Full packages are self-contained and have no local dependency;
/// Chain packages are differential patches that depend on locally installed files.
/// <c>Unspecified</c> (0) is the default value, indicating the type has not been set.
/// </summary>
public enum PackageType
{
/// <summary>
/// Not specified / not set. Used as a safe default so that an uninitialized
/// field (default(int)=0) does not silently imply Chain.
/// </summary>
Unspecified = 0,

/// <summary>
/// Sequential incremental patch (chain package).
/// Depends on the previous version being installed. Applied via binary
/// differential patch (bsdiff/hdiff) over the installed files.
/// </summary>
Chain = 1,

/// <summary>
/// Full replacement package.
/// Self-contained; no dependency on any locally installed version.
/// Applied by extracting the archive directly to the install directory
/// without any binary patching — a pure overwrite of all files.
/// </summary>
Full = 2
}
56 changes: 30 additions & 26 deletions src/c#/GeneralUpdate.Core/Configuration/VersionEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,31 +164,20 @@ public class VersionEntry : VersionIdentity
[JsonPropertyName("urlExpireTimeUtc")]
public DateTime? UrlExpireTimeUtc { get; set; }

/// <summary>
/// Whether this is a cross-version upgrade package.
/// <c>true</c> indicates this package is used to upgrade directly from an old version to a new version,
/// rather than through sequential chain upgrades.
/// </summary>
[JsonPropertyName("isCrossVersion")]
public bool? IsCrossVersion { get; set; }

/// <summary>
/// The source version number for cross-version upgrade packages.
/// Indicates which source version this differential patch can be applied to.
/// </summary>
/// <remarks>
/// Only valid when <see cref="IsCrossVersion" /> is <c>true</c>.
/// </remarks>
[JsonPropertyName("fromVersion")]
public string? FromVersion { get; set; }

/// <summary>
/// Whether this version package is frozen (archived and not used for active updates).
/// Frozen version packages will not be used for update detection or download.
/// </summary>
[JsonPropertyName("isFreeze")]
public bool? IsFreeze { get; set; }

/// <summary>
/// Package type: 0=Unspecified, 1=Chain (differential patch), 2=Full (self-contained replacement).
/// The client pipeline uses this value to decide how to apply the package.
/// </summary>
[JsonPropertyName("packageType")]
public int PackageType { get; set; }

/// <summary>
/// The minimum client version required for this package.
/// If the current client version is below this, the package is not applicable.
Expand All @@ -197,16 +186,31 @@ public class VersionEntry : VersionIdentity
public string? MinClientVersion { get; set; }

/// <summary>
/// The hash of the source full-version archive used to build this cross-version package.
/// Only valid when <see cref="IsCrossVersion"/> is <c>true</c>.
/// The filename of the fallback full replacement package (without extension).
/// Populated by <see cref="Download.DownloadPlanBuilder"/> when a full package
/// is available as fallback for this chain package.
/// Only valid when <see cref="PackageType"/> is <c>Chain</c>.
/// </summary>
[JsonPropertyName("fallbackFullName")]
public string? FallbackFullName { get; set; }

/// <summary>
/// The download URL of the fallback full replacement package.
/// </summary>
[JsonPropertyName("fallbackFullUrl")]
public string? FallbackFullUrl { get; set; }

/// <summary>
/// The SHA256 hash of the fallback full replacement package.
/// </summary>
[JsonPropertyName("sourceArchiveHash")]
public string? SourceArchiveHash { get; set; }
[JsonPropertyName("fallbackFullHash")]
public string? FallbackFullHash { get; set; }

/// <summary>
/// The hash of the target full-version archive used to build this cross-version package.
/// Only valid when <see cref="IsCrossVersion"/> is <c>true</c>.
/// The version string of the fallback full replacement package.
/// Used by <see cref="Strategy.AbstractStrategy"/> to skip chain packages
/// already covered by a previous fallback.
/// </summary>
[JsonPropertyName("targetArchiveHash")]
public string? TargetArchiveHash { get; set; }
[JsonPropertyName("fallbackFullVersion")]
public string? FallbackFullVersion { get; set; }
}
121 changes: 89 additions & 32 deletions src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ namespace GeneralUpdate.Core.Download;
/// if the current client version is below the minimum, the package is skipped.</description></item>
/// </list>
/// <para>
/// Note: This builder does not distinguish between cross-version and in-order updates;
/// each package carries its own <c>IsCrossVersion</c> metadata for downstream processing.
/// All packages are treated uniformly; the builder evaluates chain vs full packages
/// based on total download size.
/// </para>
/// </remarks>
public static class DownloadPlanBuilder
Expand Down Expand Up @@ -136,41 +136,98 @@ public static DownloadPlan Build(

if (candidates.Count == 0) return DownloadPlan.Empty;

// ── CVP-first selection ──
// If a matching cross-version package (CVP) exists whose FromVersion
// equals the client's current version, prefer it over chain packages.
// This gives the client a single-package shortcut from old → latest.
// Prefer the CVP with the highest target version when multiple CVPs match.
var matchingCvp = candidates
.Where(a => a.IsCrossVersion)
.Where(a =>
// Separate chain vs full packages.
// Treat Unspecified (0) as Chain for backward compatibility with older
// servers that do not set PackageType yet.
var chainCandidates = candidates
.Where(a => a.PackageType == (int)Configuration.PackageType.Chain
|| a.PackageType == (int)Configuration.PackageType.Unspecified)
.ToList();

var fullCandidates = candidates
.Where(a => a.PackageType == (int)Configuration.PackageType.Full)
.ToList();

// ── Chain vs Full size-based decision ──
// If a full replacement package is available and the total chain download
// size approaches or exceeds the full package size, skip chain and use full.
if (chainCandidates.Count > 0 && fullCandidates.Count > 0)
{
// Pick the latest full package (highest version) across all AppTypes
var bestFull = fullCandidates
.OrderByDescending(a => { Semver.TryParse(a.Version, out var sv); return sv; })
.First();

// Only compare against chain packages of the same AppType as bestFull.
// Mixing Client and Upgrade sizes together could trigger incorrect switching.
long chainTotal = chainCandidates
.Where(a => a.AppType == bestFull.AppType)
.Sum(a => a.Size);
var threshold = (long)(bestFull.Size * 0.8);

if (chainTotal >= threshold)
{
if (!Semver.TryParse(a.FromVersion, out var fromVer)) return false;
var localVersion = (a.AppType == (int)AppType.Upgrade)
? uv
: cv;
return fromVer == localVersion;
})
.OrderByDescending(a => { Semver.TryParse(a.Version, out var sv); return sv; })
.FirstOrDefault();
// Chain is too expensive — use full package instead.
// Supplement with chain packages for other AppTypes not covered by full.
GeneralTracer.Info($"DownloadPlanBuilder: chain total {chainTotal} >= 80% of full size {bestFull.Size}, switching to full package {bestFull.Name}");
var planAssets = new List<DownloadAsset> { bestFull };
planAssets.AddRange(chainCandidates
.Where(a => a.AppType != bestFull.AppType
|| (Semver.TryParse(a.Version, out var av)
&& Semver.TryParse(bestFull.Version, out var fv)
&& av > fv))
.OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; }));
return new DownloadPlan(planAssets, isForcibly);
}
}

if (matchingCvp != null)
// ── Chain plan with fallback fulls ──
// Use chain packages normally. Attach FallbackFull* info to each chain entry
// so that if a chain patch fails, AbstractStrategy can fall back to full.
if (fullCandidates.Count > 0)
{
// CVP covers one AppType in a single hop. Still need chain packages
// for other AppTypes, and for the same AppType beyond the CVP's target.
var cvpAppType = matchingCvp.AppType;
Semver.TryParse(matchingCvp.Version, out var cvpVersion);
var planAssets = new List<DownloadAsset> { matchingCvp };
planAssets.AddRange(candidates
.Where(a => !a.IsCrossVersion)
.Where(a => a.AppType != cvpAppType
|| (Semver.TryParse(a.Version, out var av) && av > cvpVersion))
.OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; }));
return new DownloadPlan(planAssets, isForcibly);
var fallbackFulls = new List<DownloadAsset>();

var chainWithFallback = chainCandidates
.Select(chain =>
{
// Find a matching full: same AppType + same Version (or closest)
var match = fullCandidates
.Where(f => f.AppType == chain.AppType)
.OrderBy(f => { Semver.TryParse(f.Version, out var sv); return sv; })
.FirstOrDefault(f =>
{
if (!Semver.TryParse(f.Version, out var fv)) return false;
if (!Semver.TryParse(chain.Version, out var cv)) return false;
return fv >= cv;
});

if (match != null)
{
// Add matching full to the fallback list once
if (!fallbackFulls.Any(f => f.Url == match.Url))
fallbackFulls.Add(match);

return chain with
{
FallbackFullName = match.Name,
FallbackFullUrl = match.Url,
FallbackFullHash = match.SHA256,
FallbackFullVersion = match.Version
};
}
return chain;
})
.ToList();

return new DownloadPlan(chainWithFallback, isForcibly)
{
FallbackFulls = fallbackFulls
};
}

// No matching CVP: return all chain packages sorted by version (ascending)
return new DownloadPlan(candidates, isForcibly);
// No full packages at all: return chain packages as-is
return new DownloadPlan(chainCandidates, isForcibly);
}

/// <summary>
Expand Down
16 changes: 12 additions & 4 deletions src/c#/GeneralUpdate.Core/Download/Models/DownloadAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ public record DownloadAsset(
string? SHA256,
string Version,
DownloadPriority Priority = DownloadPriority.Normal,
bool IsCrossVersion = false,
string? FromVersion = null,
int PackageType = 0,
string? MinClientVersion = null,
string? SourceArchiveHash = null,
string? TargetArchiveHash = null,
string? FallbackFullName = null,
string? FallbackFullUrl = null,
string? FallbackFullHash = null,
string? FallbackFullVersion = null,
bool IsForcibly = false,
bool IsFreeze = false,
int RecordId = 0,
Expand All @@ -28,4 +29,11 @@ public record DownloadPlan(IReadOnlyList<DownloadAsset> Assets, bool IsForcibly)
{
public static DownloadPlan Empty { get; } = new(new List<DownloadAsset>(), false);
public bool HasAssets => Assets.Count > 0;

/// <summary>
/// Full replacement packages downloaded alongside chain packages as fallback.
/// Not applied during the normal pipeline — only used when a chain package fails
/// and its <see cref="DownloadAsset.FallbackFullUrl"/> matches an entry here.
/// </summary>
public IReadOnlyList<DownloadAsset> FallbackFulls { get; init; } = new List<DownloadAsset>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,12 @@ private static DownloadAsset MapVersionEntry(VersionEntry v)
Size: v.Size ?? 0,
SHA256: v.Hash,
Version: v.Version ?? "0.0.0",
PackageType: v.PackageType,
MinClientVersion: v.MinClientVersion,
IsForcibly: v.IsForcibly == true,
IsFreeze: v.IsFreeze == true,
RecordId: v.RecordId,
AppType: v.AppType,
IsCrossVersion: v.IsCrossVersion == true,
FromVersion: v.FromVersion,
MinClientVersion: v.MinClientVersion,
SourceArchiveHash: v.SourceArchiveHash,
TargetArchiveHash: v.TargetArchiveHash,
AuthScheme: v.AuthScheme,
AuthToken: v.AuthToken
);
Expand Down
9 changes: 8 additions & 1 deletion src/c#/GeneralUpdate.Core/Pipeline/CompressMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text;
using System.Threading.Tasks;
using GeneralUpdate.Core.Compress;
using GeneralUpdate.Core.Configuration;

namespace GeneralUpdate.Core.Pipeline;

Expand Down Expand Up @@ -63,7 +64,13 @@ public Task InvokeAsync(PipelineContext context)
var encoding = context.Get<Encoding>("Encoding");
var appPath = context.Get<string>("SourcePath");
var patchEnabled = context.Get<bool?>("PatchEnabled");
var targetPath = patchEnabled == false ? appPath : patchPath;
var packageType = context.Get<int?>("PackageType");

// Full packages (PackageType=2) are self-contained: decompress directly
// to the install directory regardless of PatchEnabled.
// Chain packages need patch processing: decompress to PatchPath.
var isFullPackage = packageType == (int)PackageType.Full;
var targetPath = (patchEnabled == false || isFullPackage) ? appPath : patchPath;
GeneralTracer.Info($"CompressMiddleware.InvokeAsync: decompressing package. Format={format}, Source={sourcePath}, Target={targetPath}, PatchEnabled={patchEnabled}");
try
{
Expand Down
Loading