diff --git a/src/Server/DTOs/VerificationResultDTO.cs b/src/Server/DTOs/VerificationResultDTO.cs
index 0c129ce..7362bdf 100644
--- a/src/Server/DTOs/VerificationResultDTO.cs
+++ b/src/Server/DTOs/VerificationResultDTO.cs
@@ -3,17 +3,17 @@ namespace ServerSample.DTOs;
public record VerificationResultDTO
{
///
- /// 用于更新结果上报
+ /// 记录 ID,用于更新结果上报
///
public int RecordId { get; set; }
-
+
///
/// 包名称
///
public string? Name { get; set; }
///
- /// 补丁包hash值
+ /// 补丁包 SHA256 哈希值
///
public string? Hash { get; set; }
@@ -27,23 +27,28 @@ public record VerificationResultDTO
///
public string? Url { get; set; }
+ ///
+ /// 签名 URL 过期时间 (UTC)
+ ///
+ public DateTime? UrlExpireTimeUtc { get; set; }
+
///
/// 版本号
///
public string? Version { get; set; }
///
- /// 应用类型
+ /// 应用类型: 1=Client(主应用), 2=Upgrade(升级器)
///
public int? AppType { get; set; }
///
- /// 平台
+ /// 系统平台: 1=Windows, 2=Linux, 3=macOS
///
public int? Platform { get; set; }
///
- /// 产品id
+ /// 产品 ID
///
public string? ProductId { get; set; }
@@ -51,16 +56,39 @@ public record VerificationResultDTO
/// 是否强制更新
///
public bool? IsForcibly { get; set; }
-
+
///
- /// 文件格式
+ /// 文件格式(如 .zip)
///
- public string Format { get; set; } = string.Empty;
+ public string? Format { get; set; }
///
- /// 文件大小
+ /// 文件大小(字节)
///
public long? Size { get; set; }
-
+
+ ///
+ /// 是否冻结(冻结的包不参与更新)
+ ///
public bool? IsFreeze { get; set; }
+
+ ///
+ /// 升级模式: 1=VersionChain, 2=CrossVersion
+ ///
+ public int? UpgradeMode { get; set; }
+
+ ///
+ /// 是否为跨版本升级包
+ ///
+ public bool? IsCrossVersion { get; set; }
+
+ ///
+ /// 跨版本升级的源版本号
+ ///
+ public string? FromVersion { get; set; }
+
+ ///
+ /// 跨版本升级的目标版本号
+ ///
+ public string? ToVersion { get; set; }
}
\ No newline at end of file
diff --git a/src/Server/DTOs/VerifyDTO.cs b/src/Server/DTOs/VerifyDTO.cs
index 09826f2..6656707 100644
--- a/src/Server/DTOs/VerifyDTO.cs
+++ b/src/Server/DTOs/VerifyDTO.cs
@@ -3,27 +3,32 @@ namespace ServerSample.DTOs;
public class VerifyDTO
{
///
- /// 版本号
+ /// 客户端当前版本号
///
public string? Version { get; set; }
///
- /// 应用类型
+ /// 应用类型: 1=Client(主应用), 2=Upgrade(升级器)
///
public int? AppType { get; set; }
///
- /// 应用密钥
+ /// 应用密钥,用于服务端验证
///
public string? AppKey { get; set; }
///
- /// 系统平台
+ /// 系统平台: 1=Windows, 2=Linux, 3=macOS
///
public int? Platform { get; set; }
///
- /// 所属产品分支id
+ /// 所属产品分支 ID
///
public string? ProductId { get; set; }
+
+ ///
+ /// 升级模式: 1=VersionChain(逐版本链式升级), 2=CrossVersion(跨版本升级)
+ ///
+ public int? UpgradeMode { get; set; }
}
\ No newline at end of file
diff --git a/src/Server/Program.cs b/src/Server/Program.cs
index 433a8b0..4dcb399 100644
--- a/src/Server/Program.cs
+++ b/src/Server/Program.cs
@@ -1,47 +1,243 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Web;
using ServerSample.DTOs;
+// ================================================================
+// GeneralUpdate Sample Server — Minimal API for update delivery
+//
+// Endpoints:
+// POST /Upgrade/Verification — version validation & update discovery
+// POST /Upgrade/Report — update status reporting
+// GET /File/Download/{hash} — package download with Range support
+//
+// The server reads package metadata from wwwroot/packages/versions.json
+// and serves the corresponding .zip files.
+// ================================================================
+
var builder = WebApplication.CreateBuilder(args);
+builder.WebHost.UseUrls(builder.Configuration.GetValue("Urls") ?? "http://0.0.0.0:5000");
+
var app = builder.Build();
-int count = 0;
-string packageName = "packet_20250102230201638_1.0.0.1";
+var versionStore = LoadVersionStore(app.Environment);
+var reportStore = new ConcurrentDictionary();
+var recordIdCounter = 0;
+
+var contentRoot = Path.Combine(app.Environment.ContentRootPath, "wwwroot");
+if (!Directory.Exists(contentRoot))
+ Directory.CreateDirectory(contentRoot);
-app.MapPost("/Upgrade/Report", (ReportDTO request) =>
+// ── Request logging ────────────────────────────────────────────
+app.Use(async (ctx, next) =>
{
- return HttpResponseDTO.Success(true,"has update.");
+ var start = DateTime.UtcNow;
+ Console.WriteLine($"[{start:O}] {ctx.Request.Method} {ctx.Request.Path}{ctx.Request.QueryString}");
+ await next();
+ Console.WriteLine($"[{DateTime.UtcNow:O}] {ctx.Request.Method} {ctx.Request.Path} -> {ctx.Response.StatusCode} ({(DateTime.UtcNow - start).TotalMilliseconds:F0}ms)");
});
-app.MapPost("/Upgrade/Verification", (VerifyDTO request) =>
+// ── Verification handler (shared across URL variants) ──────────
+var handleVerification = (VerifyDTO request) =>
{
- count++;
- if (count > 2)
+ Console.WriteLine($"[Verification] AppKey={request.AppKey}, Version={request.Version}, AppType={request.AppType}, Platform={request.Platform}, ProductId={request.ProductId}, UpgradeMode={request.UpgradeMode}");
+
+ if (!Version.TryParse(request.Version ?? "0.0.0.0", out var currentVersion))
+ return Results.Ok(HttpResponseDTO>.Success(
+ Array.Empty(), "Invalid version format."));
+
+ var requestUpgradeMode = request.UpgradeMode ?? 1;
+
+ var available = versionStore
+ .Where(v =>
+ {
+ if (!Version.TryParse(v.Version, out var storeVersion)) return false;
+ if (storeVersion <= currentVersion) return false;
+ if (request.AppType.HasValue && v.AppType != request.AppType) return false;
+ if (request.Platform.HasValue && v.Platform != request.Platform) return false;
+ if (!string.IsNullOrEmpty(request.ProductId) && !string.IsNullOrEmpty(v.ProductId) &&
+ !string.Equals(v.ProductId, request.ProductId, StringComparison.OrdinalIgnoreCase))
+ return false;
+ if (requestUpgradeMode == 1 && v.IsCrossVersion == true) return false;
+ if (requestUpgradeMode == 2)
+ {
+ if (v.IsCrossVersion != true) return false;
+ if (!string.Equals(v.FromVersion, request.Version, StringComparison.OrdinalIgnoreCase))
+ return false;
+ }
+ return true;
+ })
+ .OrderByDescending(v => new Version(v.Version!))
+ .ThenBy(v => v.IsCrossVersion == true ? 1 : 0)
+ .ToList();
+
+ if (available.Count == 0)
+ return Results.Ok(HttpResponseDTO>.Success(
+ Array.Empty(), "Already up to date."));
+
+ var results = new List();
+ foreach (var v in available)
{
- return HttpResponseDTO>.Success(Enumerable.Empty(), "Upgrade completed.");
+ var recordId = Interlocked.Increment(ref recordIdCounter);
+ var downloadUrl = $"{GetBaseUrl(app)}File/Download/{HttpUtility.UrlEncode(v.Hash!)}";
+ results.Add(new VerificationResultDTO
+ {
+ RecordId = recordId, Name = v.PacketName, Hash = v.Hash,
+ ReleaseDate = v.PubTime, Url = downloadUrl,
+ UrlExpireTimeUtc = DateTime.UtcNow.AddHours(24),
+ Version = v.Version, AppType = v.AppType, Platform = v.Platform,
+ ProductId = v.ProductId, IsForcibly = v.IsForcibly,
+ Format = v.Format ?? ".zip", Size = v.Size, IsFreeze = v.IsFreeze,
+ UpgradeMode = requestUpgradeMode,
+ IsCrossVersion = v.IsCrossVersion ?? false,
+ FromVersion = v.FromVersion, ToVersion = v.ToVersion
+ });
}
- var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "wwwroot", "packages", $"{packageName}.zip");
- var packet = new FileInfo(filePath);
- var result = new List
+ var modeLabel = requestUpgradeMode == 2 ? "CrossVersion" : "VersionChain";
+ Console.WriteLine($"[Verification] Returning {results.Count} packages (Mode: {modeLabel})");
+ foreach (var r in results)
+ Console.WriteLine($" {r.Version} — {r.Name} ({(r.IsCrossVersion == true ? $"Cross {r.FromVersion}→{r.ToVersion}" : "Full")})");
+
+ return Results.Ok(HttpResponseDTO>.Success(results,
+ $"Found {results.Count} update(s)."));
+};
+
+// Register both URL variants (different clients use different paths)
+app.MapPost("/Upgrade/Verification", handleVerification);
+app.MapPost("/Update/Verification", handleVerification);
+
+// ── Report handler (shared across URL variants) ─────────────────
+var handleReport = (ReportDTO request) =>
+{
+ reportStore[request.RecordId] = request;
+ var statusText = request.Status switch { 1 => "updating", 2 => "success", 3 => "failed", _ => "unknown" };
+ var typeText = request.Type switch { 1 => "upgrade", 2 => "push", _ => "unknown" };
+ Console.WriteLine($"[Report] Record {request.RecordId} -> {statusText} (type={typeText})");
+ return Results.Ok(HttpResponseDTO.Success(true, $"Report accepted: {statusText}."));
+};
+
+app.MapPost("/Update/Report", handleReport);
+
+// ── GET /File/Download/{hash} ───────────────────────────────────
+// Serves package files by their SHA256 hash. Supports HTTP Range
+// requests for download resume.
+app.MapGet("/File/Download/{hash}", (string hash, HttpContext http) =>
+{
+ var entry = versionStore.FirstOrDefault(v =>
+ string.Equals(v.Hash, hash, StringComparison.OrdinalIgnoreCase));
+
+ var fileName = entry != null ? $"{entry.PacketName}.zip" : $"{hash}.zip";
+ var filePath = Path.Combine(contentRoot, "packages", fileName);
+
+ if (!File.Exists(filePath))
{
- new VerificationResultDTO
+ // Try finding by hash as filename
+ var candidates = Directory.GetFiles(Path.Combine(contentRoot, "packages"), "*.zip");
+ var matched = candidates.FirstOrDefault(f =>
{
- RecordId = 1,
- Name = packageName,
- Hash = "ad1a85a9169ca0083ab54ba390e085c56b9059efc3ca8aa1ec9ed857683cc4b1",
- ReleaseDate = DateTime.Now,
- Url = $"http://localhost:5000/packages/{packageName}.zip",
- Version = "1.0.0.1",
- AppType = 1,
- Platform = 1,
- ProductId = "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
- IsForcibly = false,
- Format = ".zip",
- Size = packet.Length,
- IsFreeze = false
- }
- };
- return HttpResponseDTO>.Success(result,"has update.");
+ using var sha = SHA256.Create();
+ using var fs = File.OpenRead(f);
+ var computed = Convert.ToHexStringLower(sha.ComputeHash(fs));
+ return string.Equals(computed, hash, StringComparison.OrdinalIgnoreCase);
+ });
+ if (matched != null) filePath = matched;
+ else
+ return Results.NotFound(new { Code = 404, Message = $"File not found for hash: {hash}" });
+ }
+
+ var fileInfo = new FileInfo(filePath);
+ Console.WriteLine($"[Download] {hash}: {fileInfo.Name} ({fileInfo.Length} bytes)");
+ return Results.File(filePath, contentType: "application/octet-stream",
+ enableRangeProcessing: true, fileDownloadName: Path.GetFileName(filePath));
});
+// ── Static files fallback ──────────────────────────────────────
app.UseStaticFiles();
-app.Run();
\ No newline at end of file
+
+// ── Startup banner ─────────────────────────────────────────────
+Console.WriteLine("╔══════════════════════════════════════════════════╗");
+Console.WriteLine("║ GeneralUpdate Sample Upgrade Server ║");
+Console.WriteLine("╠══════════════════════════════════════════════════╣");
+Console.WriteLine($"║ Verification: {GetBaseUrl(app)}Upgrade/Verification".PadRight(51) + "║");
+Console.WriteLine($"║ Report: {GetBaseUrl(app)}Upgrade/Report".PadRight(51) + "║");
+Console.WriteLine($"║ Download: {GetBaseUrl(app)}File/Download/{{hash}}".PadRight(51) + "║");
+Console.WriteLine($"║ Packages: {versionStore.Count} version(s) loaded".PadRight(51) + "║");
+Console.WriteLine("╚══════════════════════════════════════════════════╝");
+if (versionStore.Count > 0)
+{
+ foreach (var v in versionStore.OrderBy(v => new Version(v.Version!)))
+ Console.WriteLine($" {v.Version,-12} AppType={v.AppType} {(v.IsCrossVersion == true ? $"Cross {v.FromVersion}→{v.ToVersion}" : "Full")} {v.PacketName}");
+}
+else
+{
+ Console.WriteLine(" [Warn] No packages loaded. Run generate_packages.ps1 to create sample packages.");
+}
+
+app.Run();
+
+// ═══════════════════════════════════════════════════════════════════
+// Helpers
+// ═══════════════════════════════════════════════════════════════════
+
+static string GetBaseUrl(IHost host)
+{
+ var config = host.Services.GetRequiredService();
+ var url = config.GetValue("BaseUrl");
+ if (!string.IsNullOrEmpty(url)) return url.EndsWith('/') ? url : url + "/";
+ return "http://localhost:5000/";
+}
+
+static List LoadVersionStore(IWebHostEnvironment env)
+{
+ var jsonPath = Path.Combine(env.ContentRootPath, "wwwroot", "packages", "versions.json");
+ if (!File.Exists(jsonPath))
+ {
+ Console.WriteLine($"[Warn] versions.json not found at {jsonPath}, using empty store.");
+ return new List();
+ }
+
+ var json = File.ReadAllText(jsonPath);
+ var entries = JsonSerializer.Deserialize>(json) ?? new List();
+
+ var packagesDir = Path.Combine(env.ContentRootPath, "wwwroot", "packages");
+ foreach (var e in entries)
+ {
+ var zipPath = Path.Combine(packagesDir, $"{e.PacketName}.zip");
+ if (File.Exists(zipPath))
+ {
+ e.Size ??= new FileInfo(zipPath).Length;
+ if (string.IsNullOrEmpty(e.Hash))
+ {
+ using var sha256 = SHA256.Create();
+ using var stream = File.OpenRead(zipPath);
+ e.Hash = Convert.ToHexStringLower(sha256.ComputeHash(stream));
+ }
+ }
+ }
+
+ return entries;
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// Internal model for versions.json deserialization
+// ═══════════════════════════════════════════════════════════════════
+record VersionEntry
+{
+ public string? PacketName { get; set; }
+ public string? Hash { get; set; }
+ public string? Version { get; set; }
+ public string? Url { get; set; }
+ public DateTime? PubTime { get; set; }
+ public int? AppType { get; set; }
+ public int? Platform { get; set; }
+ public string? ProductId { get; set; }
+ public bool? IsForcibly { get; set; }
+ public string? Format { get; set; }
+ public long? Size { get; set; }
+ public bool? IsFreeze { get; set; }
+ public bool? IsCrossVersion { get; set; }
+ public string? FromVersion { get; set; }
+ public string? ToVersion { get; set; }
+}
diff --git a/src/Server/ServerSample.csproj b/src/Server/ServerSample.csproj
index 6f9e4c0..c1962fc 100644
--- a/src/Server/ServerSample.csproj
+++ b/src/Server/ServerSample.csproj
@@ -1,47 +1,18 @@
- net8.0
+ net10.0
enable
enable
-
PreserveNewest
-
+
PreserveNewest
-
-
-
-
- Always
-
-
- Always
-
-
-
-
-
-
-
-
-
-
-
-
- <_ContentIncludedByDefault Remove="ServerSample\obj\project.assets.json" />
- <_ContentIncludedByDefault Remove="ServerSample\obj\ServerSample.csproj.nuget.dgspec.json" />
-
-
-
-
-
-
diff --git a/src/Server/create_versions_json.ps1 b/src/Server/create_versions_json.ps1
new file mode 100644
index 0000000..f1755b2
--- /dev/null
+++ b/src/Server/create_versions_json.ps1
@@ -0,0 +1,69 @@
+# Creates versions.json from the .zip files in wwwroot/packages
+$packagesDir = Join-Path $PSScriptRoot "wwwroot\packages"
+$baseUrl = "http://localhost:5000/"
+$productId = "2d974e2a-31e6-4887-9bb1-b4689e98c77a"
+
+# Map filenames to metadata
+$packages = @(
+ @{Name="packet_*_full_client_2.0.0.0.zip"; Ver="2.0.0.0"; AppType=1; IsCross=$false; From=$null; To=$null},
+ @{Name="packet_*_full_client_1.0.0.2.zip"; Ver="1.0.0.2"; AppType=1; IsCross=$false; From=$null; To=$null},
+ @{Name="packet_*_full_client_1.0.0.1.zip"; Ver="1.0.0.1"; AppType=1; IsCross=$false; From=$null; To=$null},
+ @{Name="packet_*_full_upgrade_2.0.0.0.zip"; Ver="2.0.0.0"; AppType=2; IsCross=$false; From=$null; To=$null},
+ @{Name="packet_*_full_upgrade_1.0.0.2.zip"; Ver="1.0.0.2"; AppType=2; IsCross=$false; From=$null; To=$null},
+ @{Name="packet_*_full_upgrade_1.0.0.1.zip"; Ver="1.0.0.1"; AppType=2; IsCross=$false; From=$null; To=$null},
+ @{Name="packet_*_cross_client_*.zip"; Ver="2.0.0.0"; AppType=1; IsCross=$true; From="1.0.0"; To="2.0.0.0"},
+ @{Name="packet_*_cross_upgrade_*.zip"; Ver="2.0.0.0"; AppType=2; IsCross=$true; From="1.0.0.1"; To="2.0.0.0"}
+)
+
+$entries = @()
+foreach ($p in $packages) {
+ $pattern = $p.Name
+ $files = Get-ChildItem -Path $packagesDir -Filter $pattern -ErrorAction SilentlyContinue
+ foreach ($file in $files) {
+ # Skip non-.zip or the old patch_*.zip
+ if ($file.Extension -ne ".zip") { continue }
+ if ($file.Name -like "patch_*") { continue }
+
+ $hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
+ $size = $file.Length
+ $name = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
+
+ # Extract FromVersion/ToVersion from cross-version filenames
+ $fromVer = $p.From
+ $toVer = $p.To
+ if ($p.IsCross -and $file.Name -match "_([^_]+)_to_") {
+ $fromVer = $Matches[1]
+ }
+
+ $entry = [PSCustomObject]@{
+ PacketName = $name
+ Hash = $hash
+ Version = $p.Ver
+ Url = "$baseUrl`File/Download/$hash"
+ PubTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss")
+ AppType = $p.AppType
+ Platform = 1
+ ProductId = $productId
+ IsForcibly = $false
+ Format = ".zip"
+ Size = $size
+ IsFreeze = $false
+ IsCrossVersion = $p.IsCross
+ FromVersion = $fromVer
+ ToVersion = $toVer
+ }
+ $entries += $entry
+ $crossLabel = if ($p.IsCross) { "Cross $fromVer -> $toVer" } else { "Full" }
+ Write-Host " AppType=$($p.AppType) v$($p.Ver) [$crossLabel] $($file.Name) ($size bytes)"
+ }
+}
+
+if ($entries.Count -eq 0) {
+ Write-Host "[Error] No .zip packages found in $packagesDir" -ForegroundColor Red
+ exit 1
+}
+
+$json = ConvertTo-Json -InputObject @($entries) -Depth 5
+$versionsPath = Join-Path $packagesDir "versions.json"
+[System.IO.File]::WriteAllText($versionsPath, $json, [System.Text.UTF8Encoding]::new($false))
+Write-Host "`nGenerated $($entries.Count) entries -> $versionsPath" -ForegroundColor Green
diff --git a/src/Server/finalize_packages.ps1 b/src/Server/finalize_packages.ps1
new file mode 100644
index 0000000..d54d32b
--- /dev/null
+++ b/src/Server/finalize_packages.ps1
@@ -0,0 +1,99 @@
+# Finalize all upgrade packages in wwwroot/packages
+# - Adds mock content packages alongside PatchGenerator output
+# - Generates a single comprehensive versions.json
+
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+$packagesDir = Join-Path $PSScriptRoot "wwwroot\packages"
+$contentDir = Join-Path (Split-Path -Parent $PSScriptRoot) "content"
+$baseUrl = "http://localhost:5000/"
+$productId = "2d974e2a-31e6-4887-9bb1-b4689e98c77a"
+$timestamp = Get-Date -Format "yyyyMMddHHmmssfff"
+
+function Get-Hash($path) {
+ $bytes = [System.IO.File]::ReadAllBytes($path)
+ $sha = [System.Security.Cryptography.SHA256]::Create()
+ return [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace("-", "").ToLowerInvariant()
+}
+
+# Step 1: Add mock content packages (don't delete existing ones!)
+Write-Host "=== Adding mock content packages ===" -ForegroundColor Cyan
+foreach ($ver in @("1.0.0.1", "1.0.0.2")) {
+ $srcPath = Join-Path $contentDir "v$ver"
+ if (-not (Test-Path $srcPath)) { continue }
+ foreach ($appType in @(1, 2)) {
+ $label = if ($appType -eq 1) { "client" } else { "upgrade" }
+ $pkgName = "packet_${timestamp}_full_${label}_$ver"
+ $zipPath = Join-Path $packagesDir "$pkgName.zip"
+ if (-not (Test-Path $zipPath)) {
+ [System.IO.Compression.ZipFile]::CreateFromDirectory($srcPath, $zipPath,
+ [System.IO.Compression.CompressionLevel]::Optimal, $false)
+ }
+ Write-Host " $pkgName.zip"
+ }
+}
+
+# Step 2: Build comprehensive versions.json from ALL packet_*.zip files
+Write-Host ""
+Write-Host "=== Building versions.json ===" -ForegroundColor Cyan
+$zipFiles = Get-ChildItem -Path $packagesDir -Filter "packet_*.zip" | Sort-Object Name
+$entries = @()
+
+foreach ($f in $zipFiles) {
+ $hash = Get-Hash $f.FullName
+ $size = $f.Length
+ $name = [System.IO.Path]::GetFileNameWithoutExtension($f.Name)
+
+ # Determine if cross-version
+ $isCross = $f.Name -match "_cross_"
+
+ # Determine AppType
+ $appType = if ($f.Name -match "_client_") { 1 } else { 2 }
+
+ # Extract version from filename
+ $verMatch = [regex]::Match($f.Name, "_(\d+\.\d+\.\d+\.\d+)\.zip$")
+ if (-not $verMatch.Success) {
+ $verMatch = [regex]::Match($f.Name, "_(\d+\.\d+\.\d+)\.zip$")
+ }
+ $version = if ($verMatch.Success) { $verMatch.Groups[1].Value } else { "0.0.0.0" }
+
+ # Extract cross-version range
+ $fromVer = $null
+ $toVer = $null
+ if ($isCross) {
+ $crossMatch = [regex]::Match($f.Name, "_cross_.*?_(\d+\.\d+\.\d+\.\d+|\d+\.\d+\.\d+)_to_(\d+\.\d+\.\d+\.\d+|\d+\.\d+\.\d+)\.zip$")
+ if (-not $crossMatch.Success) {
+ $crossMatch = [regex]::Match($f.Name, "_cross_.*?_(\d+\.\d+\.\d+)_to_(\d+\.\d+\.\d+)\.zip$")
+ }
+ if ($crossMatch.Success) {
+ $fromVer = $crossMatch.Groups[1].Value
+ $toVer = $crossMatch.Groups[2].Value
+ }
+ }
+
+ $entry = [PSCustomObject]@{
+ PacketName = $name
+ Hash = $hash
+ Version = $version
+ Url = "$baseUrl" + "File/Download/$hash"
+ PubTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss")
+ AppType = $appType
+ Platform = 1
+ ProductId = $productId
+ IsForcibly = $false
+ Format = ".zip"
+ Size = $size
+ IsFreeze = $false
+ IsCrossVersion = $isCross
+ FromVersion = $fromVer
+ ToVersion = $toVer
+ }
+ $entries += $entry
+ $crossLabel = if ($isCross) { "Cross $fromVer -> $toVer" } else { "Full" }
+ Write-Host " AppType=$appType v$version [$crossLabel] $($f.Name) ($size bytes)"
+}
+
+$json = ConvertTo-Json -InputObject @($entries) -Depth 5
+$versionsPath = Join-Path $packagesDir "versions.json"
+[System.IO.File]::WriteAllText($versionsPath, $json, [System.Text.UTF8Encoding]::new($false))
+Write-Host ""
+Write-Host "Generated $($entries.Count) entries -> $versionsPath" -ForegroundColor Green
diff --git a/src/Server/generate_packages.ps1 b/src/Server/generate_packages.ps1
new file mode 100644
index 0000000..037854e
--- /dev/null
+++ b/src/Server/generate_packages.ps1
@@ -0,0 +1,238 @@
+# ================================================================
+# GeneralUpdate Sample Server — Upgrade Package Generator
+#
+# Generates full (VersionChain) and differential (CrossVersion)
+# upgrade packages from the sample content directories.
+#
+# Output: wwwroot/packages/*.zip + wwwroot/packages/versions.json
+#
+# For differential packages, this script invokes the PatchGenerator
+# C# project when available; otherwise generates only full packages.
+# ================================================================
+param(
+ [switch]$FullOnly # Skip differential packages (fast mode)
+)
+
+$ErrorActionPreference = "Stop"
+
+$srcDir = Split-Path -Parent $PSScriptRoot
+$contentClientDir = Join-Path $srcDir "content_client"
+$contentUpgradeDir = Join-Path $srcDir "content_upgrade"
+$packagesDir = Join-Path $PSScriptRoot "wwwroot\packages"
+$timestamp = Get-Date -Format "yyyyMMddHHmmssfff"
+$baseUrl = "http://localhost:5000/"
+$productId = "2d974e2a-31e6-4887-9bb1-b4689e98c77a"
+
+# Ensure output directory exists
+New-Item -ItemType Directory -Force -Path $packagesDir | Out-Null
+
+# Clean old generated packages
+Get-ChildItem -Path $packagesDir -Filter "packet_*.zip" | Remove-Item -Force
+Write-Host "Cleaned old packages." -ForegroundColor Cyan
+
+$allVersions = @()
+
+# ===================================================================
+# Helper functions
+# ===================================================================
+
+function Get-SHA256Hash($filePath) {
+ $stream = [System.IO.File]::OpenRead($filePath)
+ $sha = [System.Security.Cryptography.SHA256]::Create()
+ $hash = [System.BitConverter]::ToString($sha.ComputeHash($stream)).Replace("-", "").ToLowerInvariant()
+ $stream.Close()
+ return $hash
+}
+
+function New-VersionEntry {
+ param(
+ [string]$PacketName, [string]$Version, [int]$AppType,
+ [int]$Platform = 1, [string]$ProductId,
+ [bool]$IsForcibly = $false, [string]$Format = ".zip",
+ [bool]$IsCrossVersion = $false,
+ [string]$FromVersion = $null, [string]$ToVersion = $null,
+ [bool]$IsFreeze = $false
+ )
+ $zipPath = Join-Path $packagesDir "$PacketName.zip"
+ $hash = Get-SHA256Hash $zipPath
+ $size = (Get-Item $zipPath).Length
+
+ return @{
+ PacketName = $PacketName
+ Hash = $hash
+ Version = $Version
+ Url = "$baseUrl`File/Download/$hash"
+ PubTime = (Get-Date).ToString("o")
+ AppType = $AppType
+ Platform = $Platform
+ ProductId = $ProductId
+ IsForcibly = $IsForcibly
+ Format = $Format
+ Size = $size
+ IsFreeze = $IsFreeze
+ IsCrossVersion = $(if ($IsCrossVersion) { $true } else { $false })
+ FromVersion = $FromVersion
+ ToVersion = $ToVersion
+ }
+}
+
+function Add-ZipPackage {
+ param(
+ [string]$SourceDir, [string]$PacketName,
+ [string]$Version, [int]$AppType,
+ [bool]$IsCrossVersion = $false,
+ [string]$FromVersion = $null, [string]$ToVersion = $null
+ )
+ $zipPath = Join-Path $packagesDir "$PacketName.zip"
+ Write-Host " Creating: $PacketName.zip" -ForegroundColor Yellow
+
+ if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
+
+ # Use .NET for compression (available in PowerShell 5+)
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ [System.IO.Compression.ZipFile]::CreateFromDirectory(
+ $SourceDir, $zipPath,
+ [System.IO.Compression.CompressionLevel]::Optimal,
+ $false # includeBaseDirectory = false
+ )
+
+ $size = (Get-Item $zipPath).Length
+ Write-Host " Size: $size bytes" -ForegroundColor Gray
+
+ $entry = New-VersionEntry -PacketName $PacketName -Version $Version `
+ -AppType $AppType -ProductId $productId `
+ -IsCrossVersion $IsCrossVersion -FromVersion $FromVersion -ToVersion $ToVersion
+ $script:allVersions += $entry
+}
+
+# ===================================================================
+# CLIENT packages (AppType=1)
+# Current version: 1.0.0 → Target version: 2.0.0.0
+# ===================================================================
+Write-Host ""
+Write-Host "===== CLIENT Packages (AppType=1) =====" -ForegroundColor Green
+
+$clientV1 = Join-Path $contentClientDir "v1.0.0.0"
+$clientV2 = Join-Path $contentClientDir "v2.0.0.0"
+
+if ((Test-Path $clientV2) -and (Test-Path $clientV1)) {
+ # Full (VersionChain) package
+ $pkgName = "packet_${timestamp}_full_client_2.0.0.0"
+ Add-ZipPackage -SourceDir $clientV2 -PacketName $pkgName `
+ -Version "2.0.0.0" -AppType 1 -IsCrossVersion $false
+
+ if (-not $FullOnly) {
+ # Differential (CrossVersion) package via PatchGenerator
+ Write-Host " [CrossVersion] Generating differential patches via PatchGenerator..." -ForegroundColor Yellow
+ $patchGeneratorProj = Join-Path $srcDir "PatchGenerator\PatchGenerator.csproj"
+ if (Test-Path $patchGeneratorProj) {
+ Push-Location (Join-Path $srcDir "PatchGenerator")
+ try {
+ dotnet run --project $patchGeneratorProj
+ Write-Host " PatchGenerator completed." -ForegroundColor Gray
+ }
+ catch {
+ Write-Host " [Warn] PatchGenerator failed: $_" -ForegroundColor Magenta
+ Write-Host " Run 'dotnet run --project $patchGeneratorProj' manually for differential packages." -ForegroundColor Magenta
+ }
+ finally { Pop-Location }
+
+ # PatchGenerator writes directly to Server/wwwroot/packages/
+ # Also write its output to versions.json
+ $patchVersionsJson = Join-Path $packagesDir "versions.json"
+ if (Test-Path $patchVersionsJson) {
+ $patchEntries = Get-Content $patchVersionsJson -Raw | ConvertFrom-Json
+ foreach ($pe in $patchEntries) {
+ $script:allVersions += @{
+ PacketName = $pe.PacketName
+ Hash = $pe.Hash
+ Version = $pe.Version
+ Url = $pe.Url
+ PubTime = $pe.PubTime
+ AppType = $pe.AppType
+ Platform = $pe.Platform
+ ProductId = $pe.ProductId
+ IsForcibly = $pe.IsForcibly
+ Format = $pe.Format
+ Size = $pe.Size
+ IsFreeze = $pe.IsFreeze
+ IsCrossVersion = $(if ($pe.IsCrossVersion) { $true } else { $false })
+ FromVersion = $pe.FromVersion
+ ToVersion = $pe.ToVersion
+ }
+ }
+ }
+ }
+ else {
+ Write-Host " [Skip] PatchGenerator project not found at $patchGeneratorProj" -ForegroundColor Yellow
+ }
+ }
+}
+else {
+ Write-Host " [Skip] content_client directories not found: $clientV1 or $clientV2" -ForegroundColor Yellow
+}
+
+# ===================================================================
+# UPGRADE packages (AppType=2)
+# Current version: 1.0.0.1 → Target version: 2.0.0.0
+# ===================================================================
+Write-Host ""
+Write-Host "===== UPGRADE Packages (AppType=2) =====" -ForegroundColor Green
+
+$upgradeV1 = Join-Path $contentUpgradeDir "v1.0.0.0"
+$upgradeV2 = Join-Path $contentUpgradeDir "v2.0.0.0"
+
+if ((Test-Path $upgradeV2) -and (Test-Path $upgradeV1)) {
+ # Full (VersionChain) package
+ $pkgName = "packet_${timestamp}_full_upgrade_2.0.0.0"
+ Add-ZipPackage -SourceDir $upgradeV2 -PacketName $pkgName `
+ -Version "2.0.0.0" -AppType 2 -IsCrossVersion $false
+}
+else {
+ Write-Host " [Skip] content_upgrade directories not found: $upgradeV1 or $upgradeV2" -ForegroundColor Yellow
+}
+
+# ===================================================================
+# Also generate smaller mock packages from content/ dirs
+# (for quick testing without large binary packages)
+# ===================================================================
+Write-Host ""
+Write-Host "===== MOCK Content Packages =====" -ForegroundColor Green
+
+$contentDir = Join-Path $srcDir "content"
+
+foreach ($ver in @("1.0.0.1", "1.0.0.2")) {
+ $srcPath = Join-Path $contentDir "v$ver"
+ if (Test-Path $srcPath) {
+ # Client full package
+ $pkgName = "packet_${timestamp}_full_client_$ver"
+ Add-ZipPackage -SourceDir $srcPath -PacketName $pkgName `
+ -Version $ver -AppType 1 -IsCrossVersion $false
+
+ # Upgrade full package
+ $pkgName = "packet_${timestamp}_full_upgrade_$ver"
+ Add-ZipPackage -SourceDir $srcPath -PacketName $pkgName `
+ -Version $ver -AppType 2 -IsCrossVersion $false
+ }
+}
+
+# ===================================================================
+# Write versions.json
+# ===================================================================
+# Deduplicate entries (PatchGenerator may have added some already)
+$uniqueVersions = $allVersions | Sort-Object PacketName -Unique
+$versionsJsonPath = Join-Path $packagesDir "versions.json"
+$json = $uniqueVersions | ConvertTo-Json -Depth 10
+[System.IO.File]::WriteAllText($versionsJsonPath, $json, [System.Text.Encoding]::UTF8)
+
+Write-Host ""
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host " Generated $($uniqueVersions.Count) package(s)" -ForegroundColor Cyan
+Write-Host " Output: $packagesDir" -ForegroundColor Cyan
+Write-Host " Metadata: $versionsJsonPath" -ForegroundColor Cyan
+Write-Host "========================================" -ForegroundColor Cyan
+
+foreach ($v in $uniqueVersions) {
+ $cross = if ($v.IsCrossVersion) { "Cross $($v.FromVersion) → $($v.ToVersion)" } else { "Full" }
+ Write-Host " AppType=$($v.AppType) v$($v.Version) [$cross] $($v.PacketName)" -ForegroundColor Gray
+}
diff --git a/src/Server/wwwroot/packages/packet_20250102230201638_1.0.0.1.zip b/src/Server/wwwroot/packages/packet_20250102230201638_1.0.0.1.zip
deleted file mode 100644
index d8ad116..0000000
Binary files a/src/Server/wwwroot/packages/packet_20250102230201638_1.0.0.1.zip and /dev/null differ
diff --git a/src/Server/wwwroot/packages/patch_20260529221936.zip b/src/Server/wwwroot/packages/patch_20260529221936.zip
new file mode 100644
index 0000000..09f9342
Binary files /dev/null and b/src/Server/wwwroot/packages/patch_20260529221936.zip differ
diff --git a/src/Server/wwwroot/packages/versions.json b/src/Server/wwwroot/packages/versions.json
index 4e1ffa7..c9877cf 100644
--- a/src/Server/wwwroot/packages/versions.json
+++ b/src/Server/wwwroot/packages/versions.json
@@ -1,9 +1,70 @@
[
- {
- "PacketName": "packet_20250102230201638_1.0.0.1",
- "Hash": "ad1a85a9169ca0083ab54ba390e085c56b9059efc3ca8aa1ec9ed857683cc4b1",
- "Version": "1.0.0.1",
- "Url": "http://localhost:5000/packages/packet_20250102230201638_1.0.0.1.zip",
- "PubTime": "2025-01-02T23:48:21"
- }
+ {
+ "PacketName": "packet_20260531180913400_full_client_2.0.0.0",
+ "Hash": "eeb4ce8c90a8da765a1a9e00e6d02a258f368654a621b7d1991b02cdd5f27090",
+ "Version": "2.0.0.0",
+ "Url": "http://localhost:5000/File/Download/eeb4ce8c90a8da765a1a9e00e6d02a258f368654a621b7d1991b02cdd5f27090",
+ "PubTime": "2026-05-31T18:09:18",
+ "AppType": 1,
+ "Platform": 1,
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "IsForcibly": false,
+ "Format": ".zip",
+ "Size": 516566,
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null,
+ "ToVersion": null
+ },
+ {
+ "PacketName": "packet_20260531180913400_full_upgrade_2.0.0.0",
+ "Hash": "8f4ff42d68804f9a105b5c3cd59ee859ce8f8d2c32e60b38567984d6ae8ae893",
+ "Version": "2.0.0.0",
+ "Url": "http://localhost:5000/File/Download/8f4ff42d68804f9a105b5c3cd59ee859ce8f8d2c32e60b38567984d6ae8ae893",
+ "PubTime": "2026-05-31T18:09:18",
+ "AppType": 2,
+ "Platform": 1,
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "IsForcibly": false,
+ "Format": ".zip",
+ "Size": 512475,
+ "IsFreeze": false,
+ "IsCrossVersion": false,
+ "FromVersion": null,
+ "ToVersion": null
+ },
+ {
+ "PacketName": "packet_20260531180913400_cross_client_1.0.0.0_to_2.0.0.0",
+ "Hash": "18a66651e76689894d0c4053da0c710584d319b0e374fddf52e6755b6f8ee524",
+ "Version": "2.0.0.0",
+ "Url": "http://localhost:5000/File/Download/18a66651e76689894d0c4053da0c710584d319b0e374fddf52e6755b6f8ee524",
+ "PubTime": "2026-05-31T18:09:18",
+ "AppType": 1,
+ "Platform": 1,
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "IsForcibly": false,
+ "Format": ".zip",
+ "Size": 84828,
+ "IsFreeze": false,
+ "IsCrossVersion": true,
+ "FromVersion": "1.0.0.0",
+ "ToVersion": "2.0.0.0"
+ },
+ {
+ "PacketName": "packet_20260531180913400_cross_upgrade_1.0.0.0_to_2.0.0.0",
+ "Hash": "fb24b5065bd54f88fd3a131cecc34eef6f58d95cd20da180de12fe297f2a94d7",
+ "Version": "2.0.0.0",
+ "Url": "http://localhost:5000/File/Download/fb24b5065bd54f88fd3a131cecc34eef6f58d95cd20da180de12fe297f2a94d7",
+ "PubTime": "2026-05-31T18:09:18",
+ "AppType": 2,
+ "Platform": 1,
+ "ProductId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
+ "IsForcibly": false,
+ "Format": ".zip",
+ "Size": 80735,
+ "IsFreeze": false,
+ "IsCrossVersion": true,
+ "FromVersion": "1.0.0.0",
+ "ToVersion": "2.0.0.0"
+ }
]
\ No newline at end of file