Skip to content

Commit f296db3

Browse files
JusterZhuclaude
andauthored
feat: adapt simulation testing to GeneralUpdate refactored API (#77)
* feat: generate generalupdate.manifest.json alongside sample project structure When the user clicks 'Generate Sample Project Structure', the tool now also writes generalupdate.manifest.json into the sample output directory. This makes the sample output self-contained — users get both the published binaries and the update manifest in one step. Changes: - SamplePublisherService.PublishAsync: add optional ManifestModel parameter; if provided, serialize and write generalupdate.manifest.json into output root - ConfigViewModel.GenerateSample: build ManifestModel from UI fields (falling back to parsed csproj data via ManifestGeneratorService.FromCsprojInfo) and pass it to PublishAsync Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: use IsNullOrWhiteSpace in FromCsprojInfo and add semver validation to GenerateSample - ManifestGeneratorService.FromCsprojInfo: replace ?? with !string.IsNullOrWhiteSpace() so that empty/unset UI fields correctly fall back to parsed csproj values (MainAppName, AppType, UpdateAppName, UpdatePath). - ConfigViewModel.GenerateSample: run semver validation on ClientVersion and UpgradeClientVersion before writing the manifest, matching the behavior of the Generate pipeline. Fixes review feedback from Copilot on PR #75. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: adapt simulation testing to GeneralUpdate refactored API - Replace NuGet package references with project references to local GeneralUpdate.Core source - Rewrite test client (ClientSample) to use GeneralUpdateBootstrap with SetConfig() - Rewrite test upgrade (UpgradeSample) to align with GeneralUpdate-Samples/src/Upgrade pattern - Rewrite LocalUpdateServer to match GeneralUpdate-Samples/src/Server: - Version filtering via Version.TryParse (only return higher versions) - Proper VerifyDTO / VerificationResultDTO with all required fields - Always HTTP 200 with semantic Code in JSON body (never 204) - No fallback/last-resort matching that causes update loops - Update DiffService from DifferentialCore.Clean() to DiffPipeline.CleanAsync() - Switch dotnet publish from self-contained single-file to framework-dependent (matching SamplePublisherService pattern in Config Generator) - Fix trailing backslash in output directory breaking dotnet publish command line - Generate generalupdate.manifest.json in simulation app directory - Read ClientVersion from manifest on startup to prevent update loops (WriteBackClientVersion persists updated version between runs) - Add UpdatePath UI field to simulation view with localization - Use real project output names (ClientSample.exe, UpgradeSample.exe) instead of renamed names to match Config Generator directory structure Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address Copilot review feedback - Version filter: exclude unparseable versions instead of silently including them - RecordId: use stable incrementing counter instead of GetHashCode - DisposeAsync: log exceptions instead of empty catch - Comment: update Client.exe → ClientSample.exe - AppType: use switch expression for clarity --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2765bb0 commit f296db3

11 files changed

Lines changed: 355 additions & 218 deletions

src/GeneralUpdate.Tools.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
<PackageReference Include="Irihi.Ursa" Version="2.0.0" />
2929
<PackageReference Include="Irihi.Ursa.Themes.Semi" Version="2.0.0" />
3030
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
31-
<PackageReference Include="GeneralUpdate.Core" Version="10.4.6" />
31+
<ProjectReference Include="..\..\..\GeneralUpdate\src\c#\GeneralUpdate.Core\GeneralUpdate.Core.csproj" />
3232
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
3333
</ItemGroup>
3434

src/Models/SimulateConfigModel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ public partial class SimulateConfigModel : ObservableObject
1515
[ObservableProperty] private int _appType = 1;
1616
[ObservableProperty] private string _appSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6";
1717
[ObservableProperty] private string _productId = "2d974e2a-31e6-4887-9bb1-b4689e98c77a";
18+
[ObservableProperty] private string _updatePath = "update/";
1819
public int ServerPort { get; set; } = 5000;
1920
}

src/Services/DiffService.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
using System;
1+
using System;
22
using System.IO;
33
using System.Threading.Tasks;
4-
using GeneralUpdate.Differential;
4+
using GeneralUpdate.Core.Pipeline;
55

66
namespace GeneralUpdate.Tools.Services;
77

@@ -12,6 +12,8 @@ public async Task GeneratePatchAsync(string oldDir, string newDir, string patchD
1212
if (!Directory.Exists(oldDir)) throw new DirectoryNotFoundException("Old: " + oldDir);
1313
if (!Directory.Exists(newDir)) throw new DirectoryNotFoundException("New: " + newDir);
1414
Directory.CreateDirectory(patchDir);
15-
await Task.Run(() => DifferentialCore.Clean(oldDir, newDir, patchDir).GetAwaiter().GetResult());
15+
16+
var pipeline = new DiffPipeline();
17+
await pipeline.CleanAsync(oldDir, newDir, patchDir);
1618
}
17-
}
19+
}

src/Services/LocalUpdateServer.cs

Lines changed: 132 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,104 +2,128 @@
22
using System.Collections.Generic;
33
using System.IO;
44
using System.Linq;
5+
using System.Text.Json;
56
using System.Threading.Tasks;
67
using Microsoft.AspNetCore.Builder;
78
using Microsoft.AspNetCore.Hosting;
89
using Microsoft.AspNetCore.Http;
9-
using Microsoft.Extensions.DependencyInjection;
1010
using Microsoft.Extensions.Hosting;
1111

1212
namespace GeneralUpdate.Tools.Services;
1313

14+
/// <summary>
15+
/// Local mock update server — modelled after GeneralUpdate-Samples/src/Server.
16+
/// Serves version verification, status reporting, and package download endpoints.
17+
/// </summary>
1418
public class LocalUpdateServer : IAsyncDisposable
1519
{
1620
private WebApplication? _app;
1721
private int _port;
1822
private Task? _runTask;
23+
private int _nextRecordId = 1;
1924

2025
public int Port => _port;
2126
public string BaseUrl => $"http://127.0.0.1:{_port}";
2227

23-
public List<(string CurrentVersion, string TargetVersion, string Hash, string ZipPath, int AppType)> Updates { get; } = new();
28+
/// <summary>Registered updates: (CurrentVersion, TargetVersion, Hash, ZipPath, AppType, Platform, ProductId).</summary>
29+
public List<VersionRecord> Versions { get; } = new();
2430

2531
public async Task StartAsync(int port = 5000)
2632
{
2733
var builder = WebApplication.CreateBuilder();
2834
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
29-
3035
_app = builder.Build();
3136

32-
// POST /Upgrade/Verification
37+
// ── POST /Upgrade/Verification ──────────────────────────
38+
// Matches the sample server: receives VerifyDTO, returns only
39+
// versions HIGHER than the client's current version.
3340
_app.MapPost("/Upgrade/Verification", async (HttpContext context) =>
3441
{
35-
var currentVer = context.Request.Query["currentVersion"].ToString();
36-
var appTypeStr = context.Request.Query["appType"].ToString();
37-
38-
// Fallback 1: form body
39-
if (string.IsNullOrEmpty(currentVer) && context.Request.HasFormContentType)
42+
VerifyDTO? request;
43+
try
44+
{
45+
request = await JsonSerializer.DeserializeAsync<VerifyDTO>(
46+
context.Request.Body,
47+
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
48+
}
49+
catch
4050
{
41-
var form = await context.Request.ReadFormAsync();
42-
currentVer = form["currentVersion"].ToString();
43-
appTypeStr = form["appType"].ToString();
51+
request = null;
4452
}
4553

46-
// Fallback 2: JSON body
47-
if (string.IsNullOrEmpty(currentVer))
54+
if (request == null || string.IsNullOrWhiteSpace(request.Version))
4855
{
49-
try
50-
{
51-
context.Request.EnableBuffering();
52-
using var reader = new System.IO.StreamReader(context.Request.Body, System.Text.Encoding.UTF8, leaveOpen: true);
53-
var bodyText = await reader.ReadToEndAsync();
54-
context.Request.Body.Position = 0;
55-
if (!string.IsNullOrWhiteSpace(bodyText))
56-
{
57-
var json = System.Text.Json.JsonDocument.Parse(bodyText).RootElement;
58-
// Framework sends "version", not "currentVersion"
59-
if (json.TryGetProperty("version", out var cv)) currentVer = cv.GetString() ?? "";
60-
if (json.TryGetProperty("currentVersion", out var cv2)) currentVer = cv2.GetString() ?? "";
61-
if (json.TryGetProperty("appType", out var at)) appTypeStr = at.GetRawText();
62-
}
63-
}
64-
catch { }
56+
await WriteJsonAsync(context, 204, Array.Empty<VerificationResultDTO>());
57+
return;
6558
}
6659

67-
_ = int.TryParse(appTypeStr, out var appType);
60+
var clientVersion = request.Version;
61+
var appType = request.AppType;
62+
var platform = request.Platform;
63+
var productId = request.ProductId;
6864

69-
var match = Updates.Find(u => u.CurrentVersion == currentVer);
70-
// Fallback: match on AppType only if version doesn't match
71-
if (match == default)
72-
match = Updates.Find(u => u.AppType == appType);
73-
// Last resort: return first registered update
74-
if (match == default && Updates.Count > 0)
75-
match = Updates[0];
76-
if (match == default)
65+
// Filter: only return versions higher than client's current version.
66+
// This naturally breaks the update loop — once the client is at the
67+
// latest version, no updates are returned.
68+
var matches = Versions
69+
.Where(v =>
70+
{
71+
// AppType filter
72+
if (appType.HasValue && v.AppType != appType.Value) return false;
73+
// Platform filter
74+
if (platform.HasValue && v.Platform != platform.Value) return false;
75+
// ProductId filter
76+
if (!string.IsNullOrWhiteSpace(productId) &&
77+
!string.IsNullOrWhiteSpace(v.ProductId) &&
78+
!string.Equals(v.ProductId, productId, StringComparison.OrdinalIgnoreCase))
79+
return false;
80+
// Version filter: only return versions higher than client's.
81+
// Exclude unparseable versions — silently including them
82+
// would defeat the update-loop guard.
83+
if (!Version.TryParse(v.TargetVersion, out var itemVer)) return false;
84+
if (!Version.TryParse(clientVersion, out var clientVer)) return false;
85+
return itemVer > clientVer;
86+
})
87+
.OrderByDescending(v => Version.TryParse(v.TargetVersion, out var ver) ? ver : new Version(0, 0))
88+
.ToList();
89+
90+
if (matches.Count == 0)
7791
{
78-
await context.Response.WriteAsJsonAsync(new { Code = 204, Body = Array.Empty<object>() });
92+
await WriteJsonAsync(context, 200, Array.Empty<VerificationResultDTO>());
7993
return;
8094
}
8195

82-
var body = new[]
96+
var results = matches.Select(m =>
8397
{
84-
new
98+
var zipName = Path.GetFileName(m.ZipPath);
99+
var fileInfo = new FileInfo(m.ZipPath);
100+
return new VerificationResultDTO
85101
{
86-
Name = Path.GetFileName(match.ZipPath),
87-
Version = match.TargetVersion,
88-
Hash = match.Hash,
89-
Url = $"{BaseUrl}/patch/{Uri.EscapeDataString(Path.GetFileName(match.ZipPath))}",
90-
AppType = match.AppType,
91-
ReleaseDate = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
92-
IsForcibly = false
93-
}
94-
};
95-
await context.Response.WriteAsJsonAsync(new { Code = 200, Body = body });
102+
RecordId = _nextRecordId++,
103+
Name = Path.GetFileNameWithoutExtension(zipName),
104+
Version = m.TargetVersion,
105+
Hash = m.Hash,
106+
Url = $"{BaseUrl}/patch/{Uri.EscapeDataString(zipName)}",
107+
AppType = m.AppType,
108+
Platform = m.Platform,
109+
ProductId = m.ProductId,
110+
ReleaseDate = fileInfo.LastWriteTimeUtc,
111+
IsForcibly = false,
112+
Format = ".zip",
113+
Size = fileInfo.Length,
114+
IsFreeze = false,
115+
UpgradeMode = 1
116+
};
117+
}).ToList();
118+
119+
await WriteJsonAsync(context, 200, results);
96120
});
97121

98-
// POST /Upgrade/Report
122+
// ── POST /Upgrade/Report ─────────────────────────────────
99123
_app.MapPost("/Upgrade/Report", () => Results.Ok(new { Code = 200 }));
100124

101-
// GET /patch/{filename}
102-
_app.MapGet("/patch/{filename}", async (string filename) =>
125+
// ── GET /patch/{filename} ────────────────────────────────
126+
_app.MapGet("/patch/{filename}", (string filename) =>
103127
{
104128
var filePath = LocalUpdateServerFiles.TryGet(filename);
105129
if (filePath == null || !File.Exists(filePath))
@@ -108,9 +132,8 @@ public async Task StartAsync(int port = 5000)
108132
});
109133

110134
_runTask = _app.RunAsync();
111-
// Give Kestrel a moment to bind
112135
await Task.Delay(500);
113-
// Read actual port from addresses
136+
114137
var urls = _app.Urls;
115138
if (urls.Count > 0)
116139
{
@@ -129,6 +152,58 @@ public async ValueTask DisposeAsync()
129152
if (_runTask != null)
130153
await _runTask;
131154
}
155+
156+
private static async Task WriteJsonAsync<T>(HttpContext context, int code, T body)
157+
{
158+
// Always HTTP 200 — the JSON body's "Code" field carries the semantic status.
159+
// HTTP 204 forbids a response body, which would break JSON deserialization on the client.
160+
await context.Response.WriteAsJsonAsync(new { Code = code, Body = body });
161+
}
162+
}
163+
164+
// ── DTOs (matching GeneralUpdate-Samples/src/Server/DTOs) ──────────
165+
166+
public class VerifyDTO
167+
{
168+
public string? Version { get; set; }
169+
public int? AppType { get; set; }
170+
public string? AppKey { get; set; }
171+
public int? Platform { get; set; }
172+
public string? ProductId { get; set; }
173+
public int? UpgradeMode { get; set; }
174+
}
175+
176+
public class VerificationResultDTO
177+
{
178+
public int RecordId { get; set; }
179+
public string? Name { get; set; }
180+
public string? Hash { get; set; }
181+
public DateTime? ReleaseDate { get; set; }
182+
public string? Url { get; set; }
183+
public DateTime? UrlExpireTimeUtc { get; set; }
184+
public string? Version { get; set; }
185+
public int? AppType { get; set; }
186+
public int? Platform { get; set; }
187+
public string? ProductId { get; set; }
188+
public bool? IsForcibly { get; set; }
189+
public string? Format { get; set; }
190+
public long? Size { get; set; }
191+
public bool? IsFreeze { get; set; }
192+
public int? UpgradeMode { get; set; }
193+
public bool? IsCrossVersion { get; set; }
194+
public string? FromVersion { get; set; }
195+
public string? ToVersion { get; set; }
196+
}
197+
198+
public class VersionRecord
199+
{
200+
public string CurrentVersion { get; set; } = "";
201+
public string TargetVersion { get; set; } = "";
202+
public string Hash { get; set; } = "";
203+
public string ZipPath { get; set; } = "";
204+
public int AppType { get; set; } = 1;
205+
public int Platform { get; set; } = 1;
206+
public string? ProductId { get; set; }
132207
}
133208

134209
internal static class LocalUpdateServerFiles

src/Services/LocalizationService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public string this[string key]
131131
["Sim.AppType"] = "应用类型",
132132
["Sim.AppSecret"] = "应用密钥",
133133
["Sim.ProductId"] = "产品ID",
134+
["Sim.UpdatePath"] = "更新路径",
134135
["Sim.Output"] = "输出",
135136
["Sim.OutputDir"] = "模拟目录",
136137
["Sim.Start"] = "开始模拟",
@@ -267,6 +268,7 @@ public string this[string key]
267268
["Sim.AppType"] = "App Type",
268269
["Sim.AppSecret"] = "App Secret",
269270
["Sim.ProductId"] = "Product ID",
271+
["Sim.UpdatePath"] = "Update Path",
270272
["Sim.Output"] = "Output",
271273
["Sim.OutputDir"] = "Simulate Directory",
272274
["Sim.Start"] = "Start Simulation",

0 commit comments

Comments
 (0)