Skip to content

Commit 3bb6548

Browse files
authored
Merge: PackageType migration + chain/full fallback + CI fixes
Merging PackageType enum migration + chain/full fallback mechanism. - Migrate PackageType.Chain from 0→1, implement chain→full fallback on failure - Size-based full-package switching when chain total ≥ 80% of full - Remove all CVP concepts - Skip redundant chain packages after full fallback - Address Copilot review comments and fix CI failures
2 parents e476e72 + dd4ae23 commit 3bb6548

17 files changed

Lines changed: 314 additions & 197 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
namespace GeneralUpdate.Core.Configuration;
2+
3+
/// <summary>
4+
/// Defines the type of an update package.
5+
/// Full packages are self-contained and have no local dependency;
6+
/// Chain packages are differential patches that depend on locally installed files.
7+
/// <c>Unspecified</c> (0) is the default value, indicating the type has not been set.
8+
/// </summary>
9+
public enum PackageType
10+
{
11+
/// <summary>
12+
/// Not specified / not set. Used as a safe default so that an uninitialized
13+
/// field (default(int)=0) does not silently imply Chain.
14+
/// </summary>
15+
Unspecified = 0,
16+
17+
/// <summary>
18+
/// Sequential incremental patch (chain package).
19+
/// Depends on the previous version being installed. Applied via binary
20+
/// differential patch (bsdiff/hdiff) over the installed files.
21+
/// </summary>
22+
Chain = 1,
23+
24+
/// <summary>
25+
/// Full replacement package.
26+
/// Self-contained; no dependency on any locally installed version.
27+
/// Applied by extracting the archive directly to the install directory
28+
/// without any binary patching — a pure overwrite of all files.
29+
/// </summary>
30+
Full = 2
31+
}

src/c#/GeneralUpdate.Core/Configuration/VersionEntry.cs

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -164,31 +164,20 @@ public class VersionEntry : VersionIdentity
164164
[JsonPropertyName("urlExpireTimeUtc")]
165165
public DateTime? UrlExpireTimeUtc { get; set; }
166166

167-
/// <summary>
168-
/// Whether this is a cross-version upgrade package.
169-
/// <c>true</c> indicates this package is used to upgrade directly from an old version to a new version,
170-
/// rather than through sequential chain upgrades.
171-
/// </summary>
172-
[JsonPropertyName("isCrossVersion")]
173-
public bool? IsCrossVersion { get; set; }
174-
175-
/// <summary>
176-
/// The source version number for cross-version upgrade packages.
177-
/// Indicates which source version this differential patch can be applied to.
178-
/// </summary>
179-
/// <remarks>
180-
/// Only valid when <see cref="IsCrossVersion" /> is <c>true</c>.
181-
/// </remarks>
182-
[JsonPropertyName("fromVersion")]
183-
public string? FromVersion { get; set; }
184-
185167
/// <summary>
186168
/// Whether this version package is frozen (archived and not used for active updates).
187169
/// Frozen version packages will not be used for update detection or download.
188170
/// </summary>
189171
[JsonPropertyName("isFreeze")]
190172
public bool? IsFreeze { get; set; }
191173

174+
/// <summary>
175+
/// Package type: 0=Unspecified, 1=Chain (differential patch), 2=Full (self-contained replacement).
176+
/// The client pipeline uses this value to decide how to apply the package.
177+
/// </summary>
178+
[JsonPropertyName("packageType")]
179+
public int PackageType { get; set; }
180+
192181
/// <summary>
193182
/// The minimum client version required for this package.
194183
/// If the current client version is below this, the package is not applicable.
@@ -197,16 +186,31 @@ public class VersionEntry : VersionIdentity
197186
public string? MinClientVersion { get; set; }
198187

199188
/// <summary>
200-
/// The hash of the source full-version archive used to build this cross-version package.
201-
/// Only valid when <see cref="IsCrossVersion"/> is <c>true</c>.
189+
/// The filename of the fallback full replacement package (without extension).
190+
/// Populated by <see cref="Download.DownloadPlanBuilder"/> when a full package
191+
/// is available as fallback for this chain package.
192+
/// Only valid when <see cref="PackageType"/> is <c>Chain</c>.
193+
/// </summary>
194+
[JsonPropertyName("fallbackFullName")]
195+
public string? FallbackFullName { get; set; }
196+
197+
/// <summary>
198+
/// The download URL of the fallback full replacement package.
199+
/// </summary>
200+
[JsonPropertyName("fallbackFullUrl")]
201+
public string? FallbackFullUrl { get; set; }
202+
203+
/// <summary>
204+
/// The SHA256 hash of the fallback full replacement package.
202205
/// </summary>
203-
[JsonPropertyName("sourceArchiveHash")]
204-
public string? SourceArchiveHash { get; set; }
206+
[JsonPropertyName("fallbackFullHash")]
207+
public string? FallbackFullHash { get; set; }
205208

206209
/// <summary>
207-
/// The hash of the target full-version archive used to build this cross-version package.
208-
/// Only valid when <see cref="IsCrossVersion"/> is <c>true</c>.
210+
/// The version string of the fallback full replacement package.
211+
/// Used by <see cref="Strategy.AbstractStrategy"/> to skip chain packages
212+
/// already covered by a previous fallback.
209213
/// </summary>
210-
[JsonPropertyName("targetArchiveHash")]
211-
public string? TargetArchiveHash { get; set; }
214+
[JsonPropertyName("fallbackFullVersion")]
215+
public string? FallbackFullVersion { get; set; }
212216
}

src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ namespace GeneralUpdate.Core.Download;
2828
/// if the current client version is below the minimum, the package is skipped.</description></item>
2929
/// </list>
3030
/// <para>
31-
/// Note: This builder does not distinguish between cross-version and in-order updates;
32-
/// each package carries its own <c>IsCrossVersion</c> metadata for downstream processing.
31+
/// All packages are treated uniformly; the builder evaluates chain vs full packages
32+
/// based on total download size.
3333
/// </para>
3434
/// </remarks>
3535
public static class DownloadPlanBuilder
@@ -136,41 +136,98 @@ public static DownloadPlan Build(
136136

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

139-
// ── CVP-first selection ──
140-
// If a matching cross-version package (CVP) exists whose FromVersion
141-
// equals the client's current version, prefer it over chain packages.
142-
// This gives the client a single-package shortcut from old → latest.
143-
// Prefer the CVP with the highest target version when multiple CVPs match.
144-
var matchingCvp = candidates
145-
.Where(a => a.IsCrossVersion)
146-
.Where(a =>
139+
// Separate chain vs full packages.
140+
// Treat Unspecified (0) as Chain for backward compatibility with older
141+
// servers that do not set PackageType yet.
142+
var chainCandidates = candidates
143+
.Where(a => a.PackageType == (int)Configuration.PackageType.Chain
144+
|| a.PackageType == (int)Configuration.PackageType.Unspecified)
145+
.ToList();
146+
147+
var fullCandidates = candidates
148+
.Where(a => a.PackageType == (int)Configuration.PackageType.Full)
149+
.ToList();
150+
151+
// ── Chain vs Full size-based decision ──
152+
// If a full replacement package is available and the total chain download
153+
// size approaches or exceeds the full package size, skip chain and use full.
154+
if (chainCandidates.Count > 0 && fullCandidates.Count > 0)
155+
{
156+
// Pick the latest full package (highest version) across all AppTypes
157+
var bestFull = fullCandidates
158+
.OrderByDescending(a => { Semver.TryParse(a.Version, out var sv); return sv; })
159+
.First();
160+
161+
// Only compare against chain packages of the same AppType as bestFull.
162+
// Mixing Client and Upgrade sizes together could trigger incorrect switching.
163+
long chainTotal = chainCandidates
164+
.Where(a => a.AppType == bestFull.AppType)
165+
.Sum(a => a.Size);
166+
var threshold = (long)(bestFull.Size * 0.8);
167+
168+
if (chainTotal >= threshold)
147169
{
148-
if (!Semver.TryParse(a.FromVersion, out var fromVer)) return false;
149-
var localVersion = (a.AppType == (int)AppType.Upgrade)
150-
? uv
151-
: cv;
152-
return fromVer == localVersion;
153-
})
154-
.OrderByDescending(a => { Semver.TryParse(a.Version, out var sv); return sv; })
155-
.FirstOrDefault();
170+
// Chain is too expensive — use full package instead.
171+
// Supplement with chain packages for other AppTypes not covered by full.
172+
GeneralTracer.Info($"DownloadPlanBuilder: chain total {chainTotal} >= 80% of full size {bestFull.Size}, switching to full package {bestFull.Name}");
173+
var planAssets = new List<DownloadAsset> { bestFull };
174+
planAssets.AddRange(chainCandidates
175+
.Where(a => a.AppType != bestFull.AppType
176+
|| (Semver.TryParse(a.Version, out var av)
177+
&& Semver.TryParse(bestFull.Version, out var fv)
178+
&& av > fv))
179+
.OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; }));
180+
return new DownloadPlan(planAssets, isForcibly);
181+
}
182+
}
156183

157-
if (matchingCvp != null)
184+
// ── Chain plan with fallback fulls ──
185+
// Use chain packages normally. Attach FallbackFull* info to each chain entry
186+
// so that if a chain patch fails, AbstractStrategy can fall back to full.
187+
if (fullCandidates.Count > 0)
158188
{
159-
// CVP covers one AppType in a single hop. Still need chain packages
160-
// for other AppTypes, and for the same AppType beyond the CVP's target.
161-
var cvpAppType = matchingCvp.AppType;
162-
Semver.TryParse(matchingCvp.Version, out var cvpVersion);
163-
var planAssets = new List<DownloadAsset> { matchingCvp };
164-
planAssets.AddRange(candidates
165-
.Where(a => !a.IsCrossVersion)
166-
.Where(a => a.AppType != cvpAppType
167-
|| (Semver.TryParse(a.Version, out var av) && av > cvpVersion))
168-
.OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; }));
169-
return new DownloadPlan(planAssets, isForcibly);
189+
var fallbackFulls = new List<DownloadAsset>();
190+
191+
var chainWithFallback = chainCandidates
192+
.Select(chain =>
193+
{
194+
// Find a matching full: same AppType + same Version (or closest)
195+
var match = fullCandidates
196+
.Where(f => f.AppType == chain.AppType)
197+
.OrderBy(f => { Semver.TryParse(f.Version, out var sv); return sv; })
198+
.FirstOrDefault(f =>
199+
{
200+
if (!Semver.TryParse(f.Version, out var fv)) return false;
201+
if (!Semver.TryParse(chain.Version, out var cv)) return false;
202+
return fv >= cv;
203+
});
204+
205+
if (match != null)
206+
{
207+
// Add matching full to the fallback list once
208+
if (!fallbackFulls.Any(f => f.Url == match.Url))
209+
fallbackFulls.Add(match);
210+
211+
return chain with
212+
{
213+
FallbackFullName = match.Name,
214+
FallbackFullUrl = match.Url,
215+
FallbackFullHash = match.SHA256,
216+
FallbackFullVersion = match.Version
217+
};
218+
}
219+
return chain;
220+
})
221+
.ToList();
222+
223+
return new DownloadPlan(chainWithFallback, isForcibly)
224+
{
225+
FallbackFulls = fallbackFulls
226+
};
170227
}
171228

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

176233
/// <summary>

src/c#/GeneralUpdate.Core/Download/Models/DownloadAsset.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ public record DownloadAsset(
1010
string? SHA256,
1111
string Version,
1212
DownloadPriority Priority = DownloadPriority.Normal,
13-
bool IsCrossVersion = false,
14-
string? FromVersion = null,
13+
int PackageType = 0,
1514
string? MinClientVersion = null,
16-
string? SourceArchiveHash = null,
17-
string? TargetArchiveHash = null,
15+
string? FallbackFullName = null,
16+
string? FallbackFullUrl = null,
17+
string? FallbackFullHash = null,
18+
string? FallbackFullVersion = null,
1819
bool IsForcibly = false,
1920
bool IsFreeze = false,
2021
int RecordId = 0,
@@ -28,4 +29,11 @@ public record DownloadPlan(IReadOnlyList<DownloadAsset> Assets, bool IsForcibly)
2829
{
2930
public static DownloadPlan Empty { get; } = new(new List<DownloadAsset>(), false);
3031
public bool HasAssets => Assets.Count > 0;
32+
33+
/// <summary>
34+
/// Full replacement packages downloaded alongside chain packages as fallback.
35+
/// Not applied during the normal pipeline — only used when a chain package fails
36+
/// and its <see cref="DownloadAsset.FallbackFullUrl"/> matches an entry here.
37+
/// </summary>
38+
public IReadOnlyList<DownloadAsset> FallbackFulls { get; init; } = new List<DownloadAsset>();
3139
}

src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,12 @@ private static DownloadAsset MapVersionEntry(VersionEntry v)
175175
Size: v.Size ?? 0,
176176
SHA256: v.Hash,
177177
Version: v.Version ?? "0.0.0",
178+
PackageType: v.PackageType,
179+
MinClientVersion: v.MinClientVersion,
178180
IsForcibly: v.IsForcibly == true,
179181
IsFreeze: v.IsFreeze == true,
180182
RecordId: v.RecordId,
181183
AppType: v.AppType,
182-
IsCrossVersion: v.IsCrossVersion == true,
183-
FromVersion: v.FromVersion,
184-
MinClientVersion: v.MinClientVersion,
185-
SourceArchiveHash: v.SourceArchiveHash,
186-
TargetArchiveHash: v.TargetArchiveHash,
187184
AuthScheme: v.AuthScheme,
188185
AuthToken: v.AuthToken
189186
);

src/c#/GeneralUpdate.Core/Pipeline/CompressMiddleware.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Text;
22
using System.Threading.Tasks;
33
using GeneralUpdate.Core.Compress;
4+
using GeneralUpdate.Core.Configuration;
45

56
namespace GeneralUpdate.Core.Pipeline;
67

@@ -63,7 +64,13 @@ public Task InvokeAsync(PipelineContext context)
6364
var encoding = context.Get<Encoding>("Encoding");
6465
var appPath = context.Get<string>("SourcePath");
6566
var patchEnabled = context.Get<bool?>("PatchEnabled");
66-
var targetPath = patchEnabled == false ? appPath : patchPath;
67+
var packageType = context.Get<int?>("PackageType");
68+
69+
// Full packages (PackageType=2) are self-contained: decompress directly
70+
// to the install directory regardless of PatchEnabled.
71+
// Chain packages need patch processing: decompress to PatchPath.
72+
var isFullPackage = packageType == (int)PackageType.Full;
73+
var targetPath = (patchEnabled == false || isFullPackage) ? appPath : patchPath;
6774
GeneralTracer.Info($"CompressMiddleware.InvokeAsync: decompressing package. Format={format}, Source={sourcePath}, Target={targetPath}, PatchEnabled={patchEnabled}");
6875
try
6976
{

0 commit comments

Comments
 (0)