2026-05-30 11:56:50 +08:00
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using Plonds.Shared;
|
|
|
|
|
using Plonds.Shared.Models;
|
|
|
|
|
|
|
|
|
|
namespace Plonds.Core.Publishing;
|
|
|
|
|
|
|
|
|
|
public sealed class PlondsCommitDeltaBuilder
|
|
|
|
|
{
|
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
|
|
|
{
|
|
|
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
|
|
|
WriteIndented = true
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
private static readonly Dictionary<string, string[]> SourceToArtifactMap = new(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
{
|
|
|
|
|
["LanMountainDesktop"] = ["LanMountainDesktop.dll", "LanMountainDesktop.exe"],
|
|
|
|
|
["LanMountainDesktop.Launcher"] = ["LanMountainDesktop.Launcher.exe"],
|
|
|
|
|
["LanMountainDesktop.Shared.Contracts"] = ["LanMountainDesktop.Shared.Contracts.dll"],
|
|
|
|
|
["LanMountainDesktop.PluginSdk"] = ["LanMountainDesktop.PluginSdk.dll"],
|
|
|
|
|
["LanMountainDesktop.Appearance"] = ["LanMountainDesktop.Appearance.dll"],
|
|
|
|
|
["LanMountainDesktop.Settings.Core"] = ["LanMountainDesktop.Settings.Core.dll"],
|
|
|
|
|
["LanMountainDesktop.ComponentSystem"] = ["LanMountainDesktop.ComponentSystem.dll"]
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static readonly string[] FallbackAllArtifacts =
|
|
|
|
|
[
|
|
|
|
|
"LanMountainDesktop.dll",
|
|
|
|
|
"LanMountainDesktop.exe",
|
|
|
|
|
"LanMountainDesktop.Launcher.exe",
|
|
|
|
|
"LanMountainDesktop.Shared.Contracts.dll",
|
|
|
|
|
"LanMountainDesktop.PluginSdk.dll",
|
|
|
|
|
"LanMountainDesktop.Appearance.dll",
|
|
|
|
|
"LanMountainDesktop.Settings.Core.dll",
|
|
|
|
|
"LanMountainDesktop.ComponentSystem.dll"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
public PlondsDeltaBuildResult Build(PlondsCommitDeltaBuildOptions options)
|
2026-05-30 11:56:50 +08:00
|
|
|
{
|
|
|
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
var hashAlgorithm = ValidateHashAlgorithm(options.HashAlgorithm);
|
|
|
|
|
var sourceDirs = ParseSourceDirs(options.SourceDirs);
|
2026-05-30 11:56:50 +08:00
|
|
|
|
|
|
|
|
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
|
|
|
|
|
if (!File.Exists(currentPayloadZip))
|
|
|
|
|
{
|
|
|
|
|
throw new FileNotFoundException("Current payload zip not found.", currentPayloadZip);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var outputRoot = Path.GetFullPath(options.OutputRoot);
|
|
|
|
|
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
|
|
|
|
|
var currentExtractRoot = Path.Combine(workRoot, "current");
|
|
|
|
|
|
|
|
|
|
Directory.CreateDirectory(outputRoot);
|
|
|
|
|
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
|
2026-06-02 13:16:13 +08:00
|
|
|
var currentAppRoot = PlondsDeltaBuilder.ResolvePayloadAppRoot(currentExtractRoot, options.CurrentVersion);
|
2026-05-30 11:56:50 +08:00
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
var changedSourceFiles = GetChangedSourceFiles(options.BaselineTag, options.CurrentTag, sourceDirs);
|
2026-05-30 11:56:50 +08:00
|
|
|
|
|
|
|
|
if (changedSourceFiles.Count == 0)
|
|
|
|
|
{
|
2026-05-30 13:47:15 +08:00
|
|
|
Console.WriteLine("No source code changes detected between tags. Falling back to file-compare method.");
|
|
|
|
|
return FallbackToFileCompare(options, currentExtractRoot, outputRoot, hashAlgorithm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Console.WriteLine($"Detected {changedSourceFiles.Count} changed source file(s) between {options.BaselineTag} and {options.CurrentTag}.");
|
|
|
|
|
foreach (var file in changedSourceFiles.Take(20))
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine($" {file}");
|
2026-05-30 11:56:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
if (changedSourceFiles.Count > 20)
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine($" ... and {changedSourceFiles.Count - 20} more");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var artifactFiles = MapSourceToArtifacts(changedSourceFiles, sourceDirs);
|
2026-06-02 13:16:13 +08:00
|
|
|
var currentManifest = PayloadUtilities.ScanDirectory(currentAppRoot);
|
2026-05-30 11:56:50 +08:00
|
|
|
|
|
|
|
|
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
foreach (var artifactFile in artifactFiles)
|
2026-05-30 11:56:50 +08:00
|
|
|
{
|
2026-05-30 13:47:15 +08:00
|
|
|
var normalizedPath = artifactFile.Replace('\\', '/');
|
|
|
|
|
if (!currentManifest.TryGetValue(normalizedPath, out var fingerprint))
|
2026-05-30 11:56:50 +08:00
|
|
|
{
|
2026-05-30 13:47:15 +08:00
|
|
|
Console.WriteLine($" Artifact not found in current zip: {normalizedPath}, skipping.");
|
2026-05-30 11:56:50 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
var fileHash = PlondsDeltaBuilder.ComputeHash(fingerprint.FullPath, hashAlgorithm);
|
2026-05-30 11:56:50 +08:00
|
|
|
var action = PlondsConstants.ActionReplace;
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
filesMap[normalizedPath] = new PlondsFileEntry(action, fileHash, fingerprint.Size, hashAlgorithm);
|
|
|
|
|
changedFilesMap[normalizedPath] = new PlondsChangedFileEntry(normalizedPath, fileHash, fingerprint.Size, hashAlgorithm);
|
2026-05-30 11:56:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 13:16:13 +08:00
|
|
|
var changedZipPath = CreateChangedZipFromList(currentAppRoot, artifactFiles, outputRoot, options.Platform);
|
2026-05-30 11:56:50 +08:00
|
|
|
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
var launcherInChanges = artifactFiles.Any(f =>
|
|
|
|
|
string.Equals(Path.GetFileName(f), "LanMountainDesktop.Launcher.exe", StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
|
2026-05-30 11:56:50 +08:00
|
|
|
var manifest = new PlondsManifest(
|
|
|
|
|
FormatVersion: PlondsConstants.FormatVersion,
|
|
|
|
|
CurrentVersion: options.CurrentVersion,
|
2026-05-30 13:47:15 +08:00
|
|
|
PreviousVersion: options.BaselineTag.TrimStart('v'),
|
2026-05-30 11:56:50 +08:00
|
|
|
IsFullUpdate: false,
|
2026-05-30 13:47:15 +08:00
|
|
|
RequiresCleanInstall: launcherInChanges,
|
2026-05-30 11:56:50 +08:00
|
|
|
Channel: options.Channel,
|
|
|
|
|
Platform: options.Platform,
|
|
|
|
|
UpdatedAt: DateTimeOffset.UtcNow,
|
|
|
|
|
FilesMap: filesMap,
|
|
|
|
|
ChangedFilesMap: changedFilesMap,
|
|
|
|
|
Checksums: new Dictionary<string, string>
|
|
|
|
|
{
|
|
|
|
|
["changed.zip"] = $"md5:{changedZipMd5}"
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
|
|
|
|
|
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
|
|
|
|
File.WriteAllText(manifestPath, manifestJson);
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
return new PlondsDeltaBuildResult(
|
2026-05-30 11:56:50 +08:00
|
|
|
Platform: options.Platform,
|
|
|
|
|
ChangedZipPath: changedZipPath,
|
|
|
|
|
ManifestPath: manifestPath,
|
|
|
|
|
IsFullUpdate: false,
|
2026-05-30 13:47:15 +08:00
|
|
|
RequiresCleanInstall: launcherInChanges,
|
2026-05-30 11:56:50 +08:00
|
|
|
CurrentVersion: options.CurrentVersion,
|
2026-05-30 13:47:15 +08:00
|
|
|
BaselineVersion: options.BaselineTag.TrimStart('v'));
|
2026-05-30 11:56:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
private static List<string> GetChangedSourceFiles(string baselineTag, string currentTag, string[] sourceDirs)
|
2026-05-30 11:56:50 +08:00
|
|
|
{
|
2026-05-30 13:47:15 +08:00
|
|
|
var changedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
var normalizedBaseline = baselineTag.StartsWith("v") ? baselineTag : $"v{baselineTag}";
|
|
|
|
|
var normalizedCurrent = currentTag.StartsWith("v") ? currentTag : $"v{currentTag}";
|
2026-05-30 11:56:50 +08:00
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
var psi = new System.Diagnostics.ProcessStartInfo
|
2026-05-30 11:56:50 +08:00
|
|
|
{
|
2026-05-30 13:47:15 +08:00
|
|
|
FileName = "git",
|
|
|
|
|
Arguments = $"diff --name-only {normalizedBaseline}..{normalizedCurrent}",
|
|
|
|
|
RedirectStandardOutput = true,
|
|
|
|
|
UseShellExecute = false,
|
|
|
|
|
CreateNoWindow = true
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
using var process = System.Diagnostics.Process.Start(psi)
|
|
|
|
|
?? throw new InvalidOperationException("Failed to start git process.");
|
2026-05-30 11:56:50 +08:00
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
var output = process.StandardOutput.ReadToEnd();
|
|
|
|
|
process.WaitForExit();
|
2026-05-30 11:56:50 +08:00
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
if (process.ExitCode != 0)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException($"git diff failed with exit code {process.ExitCode}.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
|
|
|
|
{
|
|
|
|
|
var trimmed = line.Trim();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(trimmed))
|
2026-05-30 11:56:50 +08:00
|
|
|
{
|
2026-05-30 13:47:15 +08:00
|
|
|
continue;
|
2026-05-30 11:56:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
var isSourceFile = sourceDirs.Any(dir =>
|
|
|
|
|
trimmed.StartsWith(dir + "/", StringComparison.OrdinalIgnoreCase) ||
|
|
|
|
|
trimmed.StartsWith(dir + "\\", StringComparison.OrdinalIgnoreCase));
|
2026-05-30 11:56:50 +08:00
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
if (isSourceFile)
|
|
|
|
|
{
|
|
|
|
|
changedFiles.Add(trimmed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return changedFiles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static HashSet<string> MapSourceToArtifacts(IReadOnlyList<string> changedSourceFiles, string[] sourceDirs)
|
|
|
|
|
{
|
|
|
|
|
var artifacts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
var hasUnmappedChanges = false;
|
|
|
|
|
|
|
|
|
|
foreach (var sourceFile in changedSourceFiles)
|
|
|
|
|
{
|
|
|
|
|
var mapped = false;
|
|
|
|
|
|
|
|
|
|
foreach (var dir in sourceDirs)
|
|
|
|
|
{
|
|
|
|
|
if (!sourceFile.StartsWith(dir + "/", StringComparison.OrdinalIgnoreCase) &&
|
|
|
|
|
!sourceFile.StartsWith(dir + "\\", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (SourceToArtifactMap.TryGetValue(dir, out var artifactList))
|
|
|
|
|
{
|
|
|
|
|
foreach (var artifact in artifactList)
|
|
|
|
|
{
|
|
|
|
|
artifacts.Add(artifact);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mapped = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!mapped)
|
|
|
|
|
{
|
|
|
|
|
var extension = Path.GetExtension(sourceFile).ToLowerInvariant();
|
|
|
|
|
if (extension is ".json" or ".xml" or ".config")
|
2026-05-30 11:56:50 +08:00
|
|
|
{
|
2026-05-30 13:47:15 +08:00
|
|
|
var fileName = Path.GetFileName(sourceFile);
|
|
|
|
|
artifacts.Add(fileName);
|
|
|
|
|
mapped = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!mapped)
|
|
|
|
|
{
|
|
|
|
|
hasUnmappedChanges = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-30 11:56:50 +08:00
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
if (hasUnmappedChanges)
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine("Unmapped source changes detected. Including all core artifacts as a conservative fallback.");
|
|
|
|
|
foreach (var artifact in FallbackAllArtifacts)
|
|
|
|
|
{
|
|
|
|
|
artifacts.Add(artifact);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-30 11:56:50 +08:00
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
return artifacts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static PlondsDeltaBuildResult FallbackToFileCompare(
|
|
|
|
|
PlondsCommitDeltaBuildOptions options,
|
|
|
|
|
string currentExtractRoot,
|
|
|
|
|
string outputRoot,
|
|
|
|
|
string hashAlgorithm)
|
|
|
|
|
{
|
|
|
|
|
var fallbackZip = string.IsNullOrWhiteSpace(options.FallbackBaselineZip)
|
|
|
|
|
? null
|
|
|
|
|
: Path.GetFullPath(options.FallbackBaselineZip);
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(fallbackZip) || !File.Exists(fallbackZip))
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine("No fallback baseline zip available. Generating full update.");
|
|
|
|
|
var fullBuilder = new PlondsDeltaBuilder();
|
|
|
|
|
return fullBuilder.Build(new PlondsDeltaBuildOptions(
|
2026-05-30 11:56:50 +08:00
|
|
|
Platform: options.Platform,
|
|
|
|
|
CurrentVersion: options.CurrentVersion,
|
2026-05-30 13:47:15 +08:00
|
|
|
CurrentPayloadZip: options.CurrentPayloadZip,
|
|
|
|
|
OutputRoot: outputRoot,
|
|
|
|
|
Channel: options.Channel,
|
|
|
|
|
HashAlgorithm: hashAlgorithm,
|
|
|
|
|
LauncherRelativePath: options.LauncherRelativePath));
|
2026-05-30 11:56:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
Console.WriteLine($"Falling back to file-compare using baseline: {fallbackZip}");
|
2026-05-30 11:56:50 +08:00
|
|
|
var deltaBuilder = new PlondsDeltaBuilder();
|
2026-05-30 13:47:15 +08:00
|
|
|
return deltaBuilder.Build(new PlondsDeltaBuildOptions(
|
2026-05-30 11:56:50 +08:00
|
|
|
Platform: options.Platform,
|
|
|
|
|
CurrentVersion: options.CurrentVersion,
|
2026-05-30 13:47:15 +08:00
|
|
|
CurrentPayloadZip: options.CurrentPayloadZip,
|
2026-05-30 11:56:50 +08:00
|
|
|
OutputRoot: outputRoot,
|
|
|
|
|
Channel: options.Channel,
|
2026-05-30 13:47:15 +08:00
|
|
|
BaselineVersion: options.BaselineTag.TrimStart('v'),
|
2026-05-30 11:56:50 +08:00
|
|
|
BaselinePayloadZip: fallbackZip,
|
2026-05-30 13:47:15 +08:00
|
|
|
HashAlgorithm: hashAlgorithm,
|
|
|
|
|
LauncherRelativePath: options.LauncherRelativePath));
|
2026-05-30 11:56:50 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
private static string CreateChangedZipFromList(
|
2026-05-30 11:56:50 +08:00
|
|
|
string currentExtractRoot,
|
2026-05-30 13:47:15 +08:00
|
|
|
IEnumerable<string> artifactFiles,
|
2026-05-30 11:56:50 +08:00
|
|
|
string outputRoot,
|
|
|
|
|
string platform)
|
|
|
|
|
{
|
|
|
|
|
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
|
|
|
|
|
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
|
|
|
|
|
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
foreach (var artifactFile in artifactFiles)
|
2026-05-30 11:56:50 +08:00
|
|
|
{
|
2026-05-30 13:47:15 +08:00
|
|
|
var normalizedPath = artifactFile.Replace('\\', '/');
|
|
|
|
|
var sourcePath = Path.Combine(currentExtractRoot, normalizedPath);
|
2026-05-30 11:56:50 +08:00
|
|
|
if (!File.Exists(sourcePath))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
var destPath = Path.Combine(stagingRoot, normalizedPath);
|
2026-05-30 11:56:50 +08:00
|
|
|
var destDir = Path.GetDirectoryName(destPath);
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(destDir))
|
|
|
|
|
{
|
|
|
|
|
Directory.CreateDirectory(destDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
File.Copy(sourcePath, destPath, overwrite: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
|
|
|
|
|
return changedZipPath;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:47:15 +08:00
|
|
|
private static string ValidateHashAlgorithm(string algorithm)
|
|
|
|
|
{
|
|
|
|
|
var normalized = algorithm.Trim().ToLowerInvariant();
|
|
|
|
|
if (normalized is not (PlondsConstants.HashAlgorithmSha256 or PlondsConstants.HashAlgorithmMd5))
|
|
|
|
|
{
|
|
|
|
|
throw new ArgumentException($"Unsupported hash algorithm: {algorithm}. Supported: sha256, md5");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string[] ParseSourceDirs(string? sourceDirs)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(sourceDirs))
|
|
|
|
|
{
|
|
|
|
|
return PlondsConstants.DefaultSourceDirs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sourceDirs.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 11:56:50 +08:00
|
|
|
private static string ComputeMd5Hex(string filePath)
|
|
|
|
|
{
|
|
|
|
|
using var stream = File.OpenRead(filePath);
|
|
|
|
|
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
|
|
|
|
|
}
|
|
|
|
|
}
|