Skip to content

Commit 0757deb

Browse files
JusterZhuclaude
andcommitted
feat(core): PackageType enum migration + chain/full fallback mechanism
- Migrate PackageType.Chain from 0 to 1, add Unspecified=0 to prevent uninitialized int from silently meaning Chain - DownloadPlanBuilder: remove CVP logic; chain-total >= 80% full → use full directly; attach FallbackFull* for chain->full retry - DownloadAsset: add PackageType, FallbackFullName/Url/Hash, FallbackFulls - HttpDownloadSource: map PackageType - VersionEntry: restore PackageType, FallbackFullName/Url/Hash - ClientStrategy: propagate fallback info; download fallback fulls alongside - AbstractStrategy: on chain failure, retry with matching fallback full - CompressMiddleware: Full packages decompress to appPath, skip PatchMiddleware - Windows/Mac/LinuxStrategy: skip PatchMiddleware when PackageType==Full - Remove all CVP concepts (matchingCvp, IsCrossVersion, FromVersion, SourceArchiveHash, TargetArchiveHash) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e476e72 commit 0757deb

11 files changed

Lines changed: 215 additions & 142 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: 22 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,23 @@ 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.
202199
/// </summary>
203-
[JsonPropertyName("sourceArchiveHash")]
204-
public string? SourceArchiveHash { get; set; }
200+
[JsonPropertyName("fallbackFullUrl")]
201+
public string? FallbackFullUrl { get; set; }
205202

206203
/// <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>.
204+
/// The SHA256 hash of the fallback full replacement package.
209205
/// </summary>
210-
[JsonPropertyName("targetArchiveHash")]
211-
public string? TargetArchiveHash { get; set; }
206+
[JsonPropertyName("fallbackFullHash")]
207+
public string? FallbackFullHash { get; set; }
212208
}

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

Lines changed: 81 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,90 @@ 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+
var chainCandidates = candidates
141+
.Where(a => a.PackageType == (int)Configuration.PackageType.Chain)
142+
.ToList();
143+
144+
var fullCandidates = candidates
145+
.Where(a => a.PackageType == (int)Configuration.PackageType.Full)
146+
.ToList();
147+
148+
// ── Chain vs Full size-based decision ──
149+
// If a full replacement package is available and the total chain download
150+
// size approaches or exceeds the full package size, skip chain and use full.
151+
if (chainCandidates.Count > 0 && fullCandidates.Count > 0)
152+
{
153+
// Pick the latest full package (highest version) across all AppTypes
154+
var bestFull = fullCandidates
155+
.OrderByDescending(a => { Semver.TryParse(a.Version, out var sv); return sv; })
156+
.First();
157+
158+
long chainTotal = chainCandidates.Sum(a => a.Size);
159+
var threshold = (long)(bestFull.Size * 0.8);
160+
161+
if (chainTotal >= threshold)
147162
{
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();
163+
// Chain is too expensive — use full package instead.
164+
// Supplement with chain packages for other AppTypes not covered by full.
165+
GeneralTracer.Info($"DownloadPlanBuilder: chain total {chainTotal} >= 80% of full size {bestFull.Size}, switching to full package {bestFull.Name}");
166+
var planAssets = new List<DownloadAsset> { bestFull };
167+
planAssets.AddRange(chainCandidates
168+
.Where(a => a.AppType != bestFull.AppType
169+
|| (Semver.TryParse(a.Version, out var av)
170+
&& Semver.TryParse(bestFull.Version, out var fv)
171+
&& av > fv))
172+
.OrderBy(a => { Semver.TryParse(a.Version, out var sv); return sv; }));
173+
return new DownloadPlan(planAssets, isForcibly);
174+
}
175+
}
156176

157-
if (matchingCvp != null)
177+
// ── Chain plan with fallback fulls ──
178+
// Use chain packages normally. Attach FallbackFull* info to each chain entry
179+
// so that if a chain patch fails, AbstractStrategy can fall back to full.
180+
if (fullCandidates.Count > 0)
158181
{
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);
182+
var fallbackFulls = new List<DownloadAsset>();
183+
184+
var chainWithFallback = chainCandidates
185+
.Select(chain =>
186+
{
187+
// Find a matching full: same AppType + same Version (or closest)
188+
var match = fullCandidates
189+
.Where(f => f.AppType == chain.AppType)
190+
.OrderBy(f => { Semver.TryParse(f.Version, out var sv); return sv; })
191+
.FirstOrDefault(f =>
192+
{
193+
if (!Semver.TryParse(f.Version, out var fv)) return false;
194+
if (!Semver.TryParse(chain.Version, out var cv)) return false;
195+
return fv >= cv;
196+
});
197+
198+
if (match != null)
199+
{
200+
// Add matching full to the fallback list once
201+
if (!fallbackFulls.Any(f => f.Url == match.Url))
202+
fallbackFulls.Add(match);
203+
204+
return chain with
205+
{
206+
FallbackFullName = match.Name,
207+
FallbackFullUrl = match.Url,
208+
FallbackFullHash = match.SHA256
209+
};
210+
}
211+
return chain;
212+
})
213+
.ToList();
214+
215+
return new DownloadPlan(chainWithFallback, isForcibly)
216+
{
217+
FallbackFulls = fallbackFulls
218+
};
170219
}
171220

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

176225
/// <summary>

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ 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,
1818
bool IsForcibly = false,
1919
bool IsFreeze = false,
2020
int RecordId = 0,
@@ -28,4 +28,11 @@ public record DownloadPlan(IReadOnlyList<DownloadAsset> Assets, bool IsForcibly)
2828
{
2929
public static DownloadPlan Empty { get; } = new(new List<DownloadAsset>(), false);
3030
public bool HasAssets => Assets.Count > 0;
31+
32+
/// <summary>
33+
/// Full replacement packages downloaded alongside chain packages as fallback.
34+
/// Not applied during the normal pipeline — only used when a chain package fails
35+
/// and its <see cref="DownloadAsset.FallbackFullUrl"/> matches an entry here.
36+
/// </summary>
37+
public IReadOnlyList<DownloadAsset> FallbackFulls { get; init; } = new List<DownloadAsset>();
3138
}

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,11 @@ 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,
178179
IsForcibly: v.IsForcibly == true,
179180
IsFreeze: v.IsFreeze == true,
180181
RecordId: v.RecordId,
181182
AppType: v.AppType,
182-
IsCrossVersion: v.IsCrossVersion == true,
183-
FromVersion: v.FromVersion,
184-
MinClientVersion: v.MinClientVersion,
185-
SourceArchiveHash: v.SourceArchiveHash,
186-
TargetArchiveHash: v.TargetArchiveHash,
187183
AuthScheme: v.AuthScheme,
188184
AuthToken: v.AuthToken
189185
);

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
{

src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,31 @@ public virtual async Task ExecuteAsync()
169169
await pipelineBuilder.Build();
170170
status = ReportType.Success;
171171
}
172+
catch (Exception e) when (version.PackageType == (int)PackageType.Chain
173+
&& !string.IsNullOrEmpty(version.FallbackFullName))
174+
{
175+
GeneralTracer.Warn($"AbstractStrategy.ExecuteAsync: chain patch failed for {version.Version}, falling back to full package {version.FallbackFullName}. Error: {e.Message}");
176+
177+
// Rebuild pipeline context with the fallback full zip.
178+
// CompressMiddleware will extract directly to SourcePath,
179+
// and platform strategies skip PatchMiddleware for Full packages.
180+
var fallbackContext = new PipelineContext();
181+
var fallbackZipPath = Path.Combine(_configinfo.TempPath,
182+
$"{version.FallbackFullName}{_configinfo.Format.ToExtension()}");
183+
fallbackContext.Add("ZipFilePath", fallbackZipPath);
184+
fallbackContext.Add("Hash", version.FallbackFullHash);
185+
fallbackContext.Add("Format", _configinfo.Format);
186+
fallbackContext.Add("Encoding", _configinfo.Encoding);
187+
fallbackContext.Add("SourcePath", ResolveTargetPath(version));
188+
fallbackContext.Add("PatchPath", Path.Combine(patchRoot, "fallback"));
189+
fallbackContext.Add("PatchEnabled", false);
190+
fallbackContext.Add("PackageType", (int)PackageType.Full);
191+
fallbackContext.Add("DiffPipeline", DiffPipeline);
192+
193+
var fallbackBuilder = BuildPipeline(fallbackContext);
194+
await fallbackBuilder.Build();
195+
status = ReportType.Success;
196+
}
172197
catch (Exception e)
173198
{
174199
status = ReportType.Failure;
@@ -250,6 +275,10 @@ protected virtual PipelineContext CreatePipelineContext(VersionEntry version, st
250275
context.Add("SourcePath", sourcePath);
251276
context.Add("PatchPath", patchPath);
252277
context.Add("PatchEnabled", _configinfo.PatchEnabled);
278+
// PackageType: 0=Unspecified, 1=Chain (differential), 2=Full (self-contained).
279+
// Used by CompressMiddleware and platform strategies to decide decompression target
280+
// and whether PatchMiddleware is needed.
281+
context.Add("PackageType", version.PackageType);
253282
// DiffPipeline for parallel patch application with progress reporting
254283
context.Add("DiffPipeline", DiffPipeline);
255284

0 commit comments

Comments
 (0)