feat..去除了冗余的字体文件,又修改了PLONDS系统

This commit is contained in:
lincube
2026-05-30 11:56:50 +08:00
parent d004088601
commit 6a650873bc
61 changed files with 1849 additions and 3147 deletions

View File

@@ -1,13 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlatformPublishResult(
string Platform,
string DistributionId,
string CurrentAppDirectory,
string? PreviousDirectory,
string PreviousVersion,
string FileMapPath,
string SignaturePath,
string DistributionPath,
string LatestPath,
IReadOnlyList<string> InstallerFiles);

View File

@@ -1,9 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsBuildOptions(
string ReleaseTag,
string AssetsDirectory,
string OutputRoot,
string PrivateKeyPath,
string Repository,
string? S3BaseUrl = null);

View File

@@ -0,0 +1,157 @@
namespace Plonds.Core.Publishing;
public static class PlondsCommitAnalyzer
{
private static readonly string[] SourceDirectories =
[
"LanMountainDesktop/",
"LanMountainDesktop.Launcher/",
"LanMountainDesktop.Shared.Contracts/",
"LanMountainDesktop.PluginSdk/",
"LanMountainDesktop.Appearance/",
"LanMountainDesktop.Settings.Core/",
"LanMountainDesktop.ComponentSystem/"
];
private static readonly (string Prefix, string[] Artifacts)[] SourceToArtifactMappings =
[
("LanMountainDesktop/", ["LanMountainDesktop.dll", "LanMountainDesktop.exe"]),
("LanMountainDesktop.Launcher/", ["LanMountainDesktop.Launcher.exe", "LanMountainDesktop.Launcher.dll"]),
("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[] SourceCodeExtensions =
[
".cs", ".axaml", ".xaml", ".csproj"
];
public static HashSet<string> GetChangedSourceFiles(string baselineTag, string currentTag)
{
var changedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var start = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "git",
Arguments = $"log --name-only --pretty=format: {baselineTag}..{currentTag}",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
});
if (start is null)
{
return changedFiles;
}
var output = start.StandardOutput.ReadToEnd();
start.WaitForExit();
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim().Replace('\\', '/');
if (string.IsNullOrWhiteSpace(trimmed))
{
continue;
}
if (!IsSourceDirectoryFile(trimmed))
{
continue;
}
changedFiles.Add(trimmed);
}
return changedFiles;
}
public static HashSet<string> MapSourceFilesToArtifacts(IReadOnlySet<string> sourceFiles)
{
var artifacts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var hasUnmappedChanges = false;
foreach (var sourceFile in sourceFiles)
{
var normalized = sourceFile.Replace('\\', '/');
var mapped = false;
foreach (var (prefix, artifactList) in SourceToArtifactMappings)
{
if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!IsSourceCodeFile(normalized) && !IsConfigFile(normalized))
{
continue;
}
foreach (var artifact in artifactList)
{
artifacts.Add(artifact);
}
mapped = true;
break;
}
if (!mapped && IsConfigFile(normalized))
{
var artifactPath = MapConfigToArtifact(normalized);
if (artifactPath is not null)
{
artifacts.Add(artifactPath);
mapped = true;
}
}
if (!mapped)
{
hasUnmappedChanges = true;
}
}
if (hasUnmappedChanges)
{
foreach (var (_, artifactList) in SourceToArtifactMappings)
{
foreach (var artifact in artifactList)
{
artifacts.Add(artifact);
}
}
}
return artifacts;
}
public static bool IsSourceDirectoryFile(string path)
{
var normalized = path.Replace('\\', '/');
return SourceDirectories.Any(d => normalized.StartsWith(d, StringComparison.OrdinalIgnoreCase));
}
private static bool IsSourceCodeFile(string path)
{
var ext = Path.GetExtension(path);
return SourceCodeExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
}
private static bool IsConfigFile(string path)
{
var ext = Path.GetExtension(path);
return string.Equals(ext, ".json", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase);
}
private static string? MapConfigToArtifact(string sourcePath)
{
var fileName = Path.GetFileName(sourcePath);
return fileName;
}
}

View File

@@ -0,0 +1,14 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsCommitDeltaBuildOptions(
string Platform,
string CurrentVersion,
string CurrentPayloadZip,
string OutputRoot,
string Channel,
string BaselineTag,
string CurrentTag,
string? FallbackBaselineZip = null,
string? BaselineVersion = null,
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
string HashAlgorithm = "sha256");

View File

@@ -0,0 +1,13 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsCommitDeltaBuildResult(
string Platform,
string ChangedZipPath,
string ManifestPath,
bool IsFullUpdate,
bool RequiresCleanInstall,
bool FellBackToFileCompare,
string CurrentVersion,
string? BaselineVersion,
IReadOnlyList<string> ChangedSourceFiles,
IReadOnlyList<string> MappedArtifactFiles);

View File

@@ -0,0 +1,241 @@
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
};
public PlondsCommitDeltaBuildResult Build(PlondsCommitDeltaBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var hashAlgorithm = PlondsDeltaBuilder.ValidateHashAlgorithmInternal(options.HashAlgorithm);
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);
var changedSourceFiles = PlondsCommitAnalyzer.GetChangedSourceFiles(options.BaselineTag, options.CurrentTag);
if (changedSourceFiles.Count == 0)
{
return FallbackToFileCompare(options, currentPayloadZip, outputRoot, workRoot, hashAlgorithm);
}
var mappedArtifacts = PlondsCommitAnalyzer.MapSourceFilesToArtifacts(changedSourceFiles);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var artifact in mappedArtifacts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
if (!currentManifest.TryGetValue(artifact, out var fingerprint))
{
continue;
}
var hash = GetHash(fingerprint, hashAlgorithm);
var action = PlondsConstants.ActionReplace;
filesMap[artifact] = new PlondsFileEntry(action, hash, fingerprint.Size, hashAlgorithm);
changedFilesMap[artifact] = new PlondsChangedFileEntry(artifact, hash, fingerprint.Size, hashAlgorithm);
}
var changedZipPath = CreateChangedZipFromArtifacts(currentExtractRoot, mappedArtifacts, outputRoot, options.Platform);
var requiresCleanInstall = mappedArtifacts.Contains(options.LauncherRelativePath, StringComparer.OrdinalIgnoreCase);
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var manifest = new PlondsManifest(
FormatVersion: PlondsConstants.FormatVersion,
CurrentVersion: options.CurrentVersion,
PreviousVersion: options.BaselineVersion ?? options.BaselineTag.TrimStart('v'),
IsFullUpdate: false,
RequiresCleanInstall: requiresCleanInstall,
Channel: options.Channel,
Platform: options.Platform,
UpdatedAt: DateTimeOffset.UtcNow,
CompareMethod: PlondsConstants.CompareMethodCommitAnalyze,
HashAlgorithm: hashAlgorithm,
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);
return new PlondsCommitDeltaBuildResult(
Platform: options.Platform,
ChangedZipPath: changedZipPath,
ManifestPath: manifestPath,
IsFullUpdate: false,
RequiresCleanInstall: requiresCleanInstall,
FellBackToFileCompare: false,
CurrentVersion: options.CurrentVersion,
BaselineVersion: options.BaselineVersion,
ChangedSourceFiles: changedSourceFiles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(),
MappedArtifactFiles: mappedArtifacts.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray());
}
private PlondsCommitDeltaBuildResult FallbackToFileCompare(
PlondsCommitDeltaBuildOptions options,
string currentPayloadZip,
string outputRoot,
string workRoot,
string hashAlgorithm)
{
var fallbackZip = string.IsNullOrWhiteSpace(options.FallbackBaselineZip)
? null
: Path.GetFullPath(options.FallbackBaselineZip);
if (string.IsNullOrWhiteSpace(fallbackZip) || !File.Exists(fallbackZip))
{
var currentExtractRoot = Path.Combine(workRoot, "current");
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
var fp = currentManifest[path];
var hash = GetHash(fp, hashAlgorithm);
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionAdd, hash, fp.Size, hashAlgorithm);
changedFilesMap[path] = new PlondsChangedFileEntry(path, hash, fp.Size, hashAlgorithm);
}
var changedZipPath = CreateChangedZipFromArtifacts(currentExtractRoot, filesMap.Keys.ToHashSet(), outputRoot, options.Platform);
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var manifest = new PlondsManifest(
FormatVersion: PlondsConstants.FormatVersion,
CurrentVersion: options.CurrentVersion,
PreviousVersion: "0.0.0",
IsFullUpdate: true,
RequiresCleanInstall: false,
Channel: options.Channel,
Platform: options.Platform,
UpdatedAt: DateTimeOffset.UtcNow,
CompareMethod: PlondsConstants.CompareMethodCommitAnalyze,
HashAlgorithm: hashAlgorithm,
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);
return new PlondsCommitDeltaBuildResult(
Platform: options.Platform,
ChangedZipPath: changedZipPath,
ManifestPath: manifestPath,
IsFullUpdate: true,
RequiresCleanInstall: false,
FellBackToFileCompare: true,
CurrentVersion: options.CurrentVersion,
BaselineVersion: options.BaselineVersion,
ChangedSourceFiles: [],
MappedArtifactFiles: []);
}
var deltaBuilder = new PlondsDeltaBuilder();
var deltaResult = deltaBuilder.Build(new PlondsDeltaBuildOptions(
Platform: options.Platform,
CurrentVersion: options.CurrentVersion,
CurrentPayloadZip: currentPayloadZip,
OutputRoot: outputRoot,
Channel: options.Channel,
BaselineVersion: options.BaselineVersion,
BaselinePayloadZip: fallbackZip,
LauncherRelativePath: options.LauncherRelativePath,
HashAlgorithm: hashAlgorithm));
return new PlondsCommitDeltaBuildResult(
Platform: deltaResult.Platform,
ChangedZipPath: deltaResult.ChangedZipPath,
ManifestPath: deltaResult.ManifestPath,
IsFullUpdate: deltaResult.IsFullUpdate,
RequiresCleanInstall: deltaResult.RequiresCleanInstall,
FellBackToFileCompare: true,
CurrentVersion: deltaResult.CurrentVersion,
BaselineVersion: deltaResult.BaselineVersion,
ChangedSourceFiles: [],
MappedArtifactFiles: []);
}
private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
{
if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
{
return ComputeMd5Hex(fingerprint.FullPath);
}
return fingerprint.Sha256;
}
private static string CreateChangedZipFromArtifacts(
string currentExtractRoot,
IReadOnlySet<string> artifacts,
string outputRoot,
string platform)
{
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
foreach (var artifact in artifacts)
{
var sourcePath = Path.Combine(currentExtractRoot, artifact);
if (!File.Exists(sourcePath))
{
continue;
}
var destPath = Path.Combine(stagingRoot, artifact);
var destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrWhiteSpace(destDir))
{
Directory.CreateDirectory(destDir);
}
File.Copy(sourcePath, destPath, overwrite: true);
}
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
return changedZipPath;
}
private static string ComputeMd5Hex(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
}
}

View File

@@ -1,16 +1,12 @@
namespace Plonds.Core.Publishing;
namespace Plonds.Core.Publishing;
public sealed record PlondsDeltaBuildOptions(
string Platform,
string CurrentVersion,
string CurrentTag,
string CurrentPayloadZip,
string OutputRoot,
string PrivateKeyPath,
string Channel = "stable",
string? BaselineVersion = null,
string? BaselineTag = null,
string? BaselinePayloadZip = null,
bool IsFullPayload = false,
string? StaticOutputRoot = null,
string? UpdateBaseUrl = null);
string LauncherRelativePath = "LanMountainDesktop.Launcher.exe",
string HashAlgorithm = "sha256");

View File

@@ -1,13 +1,10 @@
namespace Plonds.Core.Publishing;
namespace Plonds.Core.Publishing;
public sealed record PlondsDeltaBuildResult(
string Platform,
string DistributionId,
string UpdateArchivePath,
string FileMapPath,
string FileMapSignaturePath,
string SummaryPath,
bool IsFullPayload,
string? BaselineTag,
string? BaselineVersion,
string TargetVersion);
string ChangedZipPath,
string ManifestPath,
bool IsFullUpdate,
bool RequiresCleanInstall,
string CurrentVersion,
string? BaselineVersion);

View File

@@ -1,16 +1,24 @@
using Plonds.Core.Security;
using System.Security.Cryptography;
using System.Text.Json;
using Plonds.Shared;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsDeltaBuilder
{
private readonly RsaFileSigner _signer = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var hashAlgorithm = ValidateHashAlgorithmInternal(options.HashAlgorithm);
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
if (!File.Exists(currentPayloadZip))
{
@@ -29,332 +37,200 @@ public sealed class PlondsDeltaBuilder
var workRoot = Path.Combine(outputRoot, "work", options.Platform);
var currentExtractRoot = Path.Combine(workRoot, "current");
var baselineExtractRoot = Path.Combine(workRoot, "baseline");
var objectsRoot = Path.Combine(workRoot, "objects");
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
var summaryRoot = Path.Combine(outputRoot, "platform-summaries");
Directory.CreateDirectory(releaseAssetsRoot);
Directory.CreateDirectory(summaryRoot);
Directory.CreateDirectory(outputRoot);
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip);
if (useFullPayload)
{
PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot);
}
else
var isFullUpdate = string.IsNullOrWhiteSpace(baselinePayloadZip);
if (!isFullUpdate)
{
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
}
PayloadUtilities.EnsureCleanDirectory(objectsRoot);
var previousManifest = useFullPayload
var previousManifest = isFullUpdate
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var updateBaseUrl = string.IsNullOrWhiteSpace(options.UpdateBaseUrl)
? null
: options.UpdateBaseUrl.TrimEnd('/');
var repoBaseUrl = string.IsNullOrWhiteSpace(updateBaseUrl)
? null
: $"{updateBaseUrl}/repo/sha256";
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot, repoBaseUrl);
var updateAssetName = $"update-{options.Platform}.zip";
var fileMapAssetName = $"plonds-filemap-{options.Platform}.json";
var fileMapSignatureAssetName = fileMapAssetName + ".sig";
var distributionId = $"plonds-{options.CurrentVersion}-{options.Platform}";
var updateArchivePath = Path.Combine(releaseAssetsRoot, updateAssetName);
var fileMapPath = Path.Combine(releaseAssetsRoot, fileMapAssetName);
var fileMapSignaturePath = Path.Combine(releaseAssetsRoot, fileMapSignatureAssetName);
var filesMap = BuildFilesMap(previousManifest, currentManifest, hashAlgorithm);
var changedFilesMap = BuildChangedFilesMap(filesMap, hashAlgorithm);
PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath);
var changedZipPath = CreateChangedZip(currentExtractRoot, filesMap, outputRoot, options.Platform);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["protocol"] = "PLONDS",
["channel"] = options.Channel,
["releaseTag"] = options.CurrentTag,
["baselineTag"] = options.BaselineTag ?? string.Empty,
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
["targetVersion"] = options.CurrentVersion,
["isFullPayload"] = useFullPayload ? "true" : "false"
};
var launcherChanged = DetectLauncherChange(previousManifest, currentManifest, options.LauncherRelativePath);
var requiresCleanInstall = launcherChanged && !isFullUpdate;
var generatedAt = DateTimeOffset.UtcNow;
var component = new ComponentDocument(
Name: "app",
Version: options.CurrentVersion,
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["component"] = "app",
["mode"] = "file-object"
},
Files: fileEntries);
var changedZipMd5 = ComputeMd5Hex(changedZipPath);
var fileMap = new FileMapDocument(
FormatVersion: "1.0",
DistributionId: distributionId,
FromVersion: options.BaselineVersion ?? "0.0.0",
ToVersion: options.CurrentVersion,
Version: options.CurrentVersion,
Platform: options.Platform,
Arch: PayloadUtilities.ResolveArch(options.Platform),
Channel: options.Channel,
GeneratedAt: generatedAt,
Metadata: metadata,
Components: [component],
Files: fileEntries);
PayloadUtilities.WriteJson(fileMapPath, fileMap);
_signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath);
if (!string.IsNullOrWhiteSpace(options.StaticOutputRoot) && !string.IsNullOrWhiteSpace(updateBaseUrl))
{
WriteStaticLayout(
options,
component,
objectsRoot,
distributionId,
fileMapPath,
fileMapSignaturePath,
Path.GetFullPath(options.StaticOutputRoot),
updateBaseUrl,
generatedAt);
}
var summary = new PlondsReleasePlatformEntry(
Platform: options.Platform,
DistributionId: distributionId,
BaselineTag: options.BaselineTag,
BaselineVersion: options.BaselineVersion ?? "0.0.0",
TargetVersion: options.CurrentVersion,
IsFullPayload: useFullPayload,
FilesZipAsset: $"files-{options.Platform}.zip",
UpdateZipAsset: updateAssetName,
FileMapAsset: fileMapAssetName,
FileMapSignatureAsset: fileMapSignatureAssetName,
Sha256: PayloadUtilities.ComputeSha256(updateArchivePath));
var summaryPath = Path.Combine(summaryRoot, $"platform-summary-{options.Platform}.json");
PayloadUtilities.WriteJson(summaryPath, summary);
return new PlondsDeltaBuildResult(
options.Platform,
distributionId,
updateArchivePath,
fileMapPath,
fileMapSignaturePath,
summaryPath,
useFullPayload,
options.BaselineTag,
options.BaselineVersion,
options.CurrentVersion);
}
private static List<FileEntryDocument> BuildFileEntries(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string objectsRoot,
string? repoBaseUrl)
{
var result = new List<FileEntryDocument>();
foreach (var path in currentManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
{
var current = currentManifest[path];
if (previousManifest.TryGetValue(path, out var previous) &&
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
{
result.Add(new FileEntryDocument(
Path: path,
Action: "reuse",
Sha256: current.Sha256,
Size: current.Size,
ObjectPath: null,
ObjectKey: null,
ObjectUrl: null,
Metadata: null));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
? null
: $"{repoBaseUrl.TrimEnd('/')}/{objectPath}";
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["mode"] = "file-object"
};
if (!string.IsNullOrWhiteSpace(current.UnixFileMode))
{
metadata["unixFileMode"] = current.UnixFileMode!;
}
result.Add(new FileEntryDocument(
Path: path,
Action: action,
Sha256: current.Sha256,
Size: current.Size,
ObjectPath: objectPath,
ObjectKey: objectPath,
ObjectUrl: objectUrl,
Metadata: metadata));
}
foreach (var path in previousManifest.Keys.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase))
{
if (currentManifest.ContainsKey(path))
{
continue;
}
result.Add(new FileEntryDocument(
Path: path,
Action: "delete",
Sha256: string.Empty,
Size: 0,
ObjectPath: null,
ObjectKey: null,
ObjectUrl: null,
Metadata: null));
}
return result;
}
private static void WriteStaticLayout(
PlondsDeltaBuildOptions options,
ComponentDocument component,
string objectsRoot,
string distributionId,
string fileMapPath,
string fileMapSignaturePath,
string staticOutputRoot,
string updateBaseUrl,
DateTimeOffset generatedAt)
{
var repoRoot = Path.Combine(staticOutputRoot, "repo", "sha256");
var manifestRoot = Path.Combine(staticOutputRoot, "manifests", distributionId);
var distributionRoot = Path.Combine(staticOutputRoot, "meta", "distributions");
var channelRoot = Path.Combine(staticOutputRoot, "meta", "channels", options.Channel, options.Platform);
CopyDirectory(objectsRoot, repoRoot);
Directory.CreateDirectory(manifestRoot);
File.Copy(fileMapPath, Path.Combine(manifestRoot, "plonds-filemap.json"), overwrite: true);
File.Copy(fileMapSignaturePath, Path.Combine(manifestRoot, "plonds-filemap.json.sig"), overwrite: true);
var fileMapUrl = $"{updateBaseUrl}/manifests/{Uri.EscapeDataString(distributionId)}/plonds-filemap.json";
var distribution = new DistributionDocument(
DistributionId: distributionId,
Version: options.CurrentVersion,
SourceVersion: options.BaselineVersion ?? "0.0.0",
var manifest = new PlondsManifest(
FormatVersion: PlondsConstants.FormatVersion,
CurrentVersion: options.CurrentVersion,
PreviousVersion: options.BaselineVersion ?? "0.0.0",
IsFullUpdate: isFullUpdate,
RequiresCleanInstall: requiresCleanInstall,
Channel: options.Channel,
Platform: options.Platform,
Arch: PayloadUtilities.ResolveArch(options.Platform),
PublishedAt: generatedAt,
FileMapUrl: fileMapUrl,
FileMapSignatureUrl: fileMapUrl + ".sig",
Components: [component],
InstallerMirrors: [],
Capabilities: ["file-object"],
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
UpdatedAt: DateTimeOffset.UtcNow,
CompareMethod: PlondsConstants.CompareMethodFileCompare,
HashAlgorithm: hashAlgorithm,
FilesMap: filesMap,
ChangedFilesMap: changedFilesMap,
Checksums: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
["releaseTag"] = options.CurrentTag,
["baselineTag"] = options.BaselineTag ?? string.Empty,
["baselineVersion"] = options.BaselineVersion ?? "0.0.0",
["targetVersion"] = options.CurrentVersion,
["isFullPayload"] = options.IsFullPayload ? "true" : "false"
["changed.zip"] = $"md5:{changedZipMd5}"
});
var latest = new LatestPointerDocument(
DistributionId: distributionId,
Version: options.CurrentVersion,
Channel: options.Channel,
var manifestPath = Path.Combine(outputRoot, "PLONDS.json");
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
File.WriteAllText(manifestPath, manifestJson);
return new PlondsDeltaBuildResult(
Platform: options.Platform,
PublishedAt: generatedAt);
PayloadUtilities.WriteJson(Path.Combine(distributionRoot, distributionId + ".json"), distribution);
PayloadUtilities.WriteJson(Path.Combine(channelRoot, "latest.json"), latest);
ChangedZipPath: changedZipPath,
ManifestPath: manifestPath,
IsFullUpdate: isFullUpdate,
RequiresCleanInstall: requiresCleanInstall,
CurrentVersion: options.CurrentVersion,
BaselineVersion: options.BaselineVersion);
}
private static void CopyDirectory(string sourceDir, string destinationDir)
internal static string ValidateHashAlgorithmInternal(string algorithm)
{
Directory.CreateDirectory(destinationDir);
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
var normalized = algorithm.Trim().ToLowerInvariant();
if (normalized is not (PlondsConstants.HashAlgorithmSha256 or PlondsConstants.HashAlgorithmMd5))
{
var relativePath = Path.GetRelativePath(sourceDir, directory);
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
throw new ArgumentException($"Unsupported hash algorithm: {algorithm}. Supported: sha256, md5");
}
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDir, file);
var destinationPath = Path.Combine(destinationDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(file, destinationPath, overwrite: true);
}
return normalized;
}
private sealed record FileMapDocument(
string FormatVersion,
string DistributionId,
string FromVersion,
string ToVersion,
string Version,
string Platform,
string Arch,
string Channel,
DateTimeOffset GeneratedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<FileEntryDocument> Files);
private static Dictionary<string, PlondsFileEntry> BuildFilesMap(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string hashAlgorithm)
{
var filesMap = new Dictionary<string, PlondsFileEntry>(StringComparer.OrdinalIgnoreCase);
private sealed record ComponentDocument(
string Name,
string Version,
IReadOnlyDictionary<string, string>? Metadata,
IReadOnlyList<FileEntryDocument> Files);
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
var current = currentManifest[path];
var currentHash = GetHash(current, hashAlgorithm);
private sealed record FileEntryDocument(
string Path,
string Action,
string Sha256,
long Size,
string? ObjectPath,
string? ObjectKey,
string? ObjectUrl,
IReadOnlyDictionary<string, string>? Metadata);
if (previousManifest.TryGetValue(path, out var previous))
{
var previousHash = GetHash(previous, hashAlgorithm);
if (string.Equals(currentHash, previousHash, StringComparison.OrdinalIgnoreCase))
{
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionReuse, currentHash, current.Size, hashAlgorithm);
continue;
}
}
private sealed record DistributionDocument(
string DistributionId,
string Version,
string SourceVersion,
string Channel,
string Platform,
string Arch,
DateTimeOffset PublishedAt,
string FileMapUrl,
string FileMapSignatureUrl,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string>? Metadata);
var action = previousManifest.ContainsKey(path)
? PlondsConstants.ActionReplace
: PlondsConstants.ActionAdd;
filesMap[path] = new PlondsFileEntry(action, currentHash, current.Size, hashAlgorithm);
}
private sealed record LatestPointerDocument(
string DistributionId,
string Version,
string Channel,
string Platform,
DateTimeOffset PublishedAt);
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
if (!currentManifest.ContainsKey(path))
{
filesMap[path] = new PlondsFileEntry(PlondsConstants.ActionDelete, string.Empty, 0, hashAlgorithm);
}
}
private sealed record InstallerMirrorDocument(
string Platform,
string? Url,
string? FileName,
string? Sha256,
long Size);
return filesMap;
}
private static string GetHash(PayloadUtilities.FileFingerprint fingerprint, string hashAlgorithm)
{
if (hashAlgorithm == PlondsConstants.HashAlgorithmMd5)
{
return ComputeMd5Hex(fingerprint.FullPath);
}
return fingerprint.Sha256;
}
private static Dictionary<string, PlondsChangedFileEntry> BuildChangedFilesMap(
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
string hashAlgorithm)
{
var changedFilesMap = new Dictionary<string, PlondsChangedFileEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var (path, entry) in filesMap)
{
if (entry.Action is PlondsConstants.ActionAdd or PlondsConstants.ActionReplace)
{
changedFilesMap[path] = new PlondsChangedFileEntry(path, entry.Hash, entry.Size, hashAlgorithm);
}
}
return changedFilesMap;
}
private static string CreateChangedZip(
string currentExtractRoot,
IReadOnlyDictionary<string, PlondsFileEntry> filesMap,
string outputRoot,
string platform)
{
var changedZipPath = Path.Combine(outputRoot, "changed.zip");
var stagingRoot = Path.Combine(outputRoot, "work", platform, "staging");
PayloadUtilities.EnsureCleanDirectory(stagingRoot);
foreach (var (path, entry) in filesMap)
{
if (entry.Action is not (PlondsConstants.ActionAdd or PlondsConstants.ActionReplace))
{
continue;
}
var sourcePath = Path.Combine(currentExtractRoot, path);
if (!File.Exists(sourcePath))
{
continue;
}
var destPath = Path.Combine(stagingRoot, path);
var destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrWhiteSpace(destDir))
{
Directory.CreateDirectory(destDir);
}
File.Copy(sourcePath, destPath, overwrite: true);
}
PayloadUtilities.CreatePayloadZip(stagingRoot, changedZipPath);
return changedZipPath;
}
private static bool DetectLauncherChange(
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> previousManifest,
IReadOnlyDictionary<string, PayloadUtilities.FileFingerprint> currentManifest,
string launcherRelativePath)
{
var normalizedPath = launcherRelativePath.Replace('\\', '/');
if (!currentManifest.TryGetValue(normalizedPath, out var current))
{
return false;
}
if (!previousManifest.TryGetValue(normalizedPath, out var previous))
{
return true;
}
return !string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase);
}
private static string ComputeMd5Hex(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(MD5.HashData(stream)).ToLowerInvariant();
}
}

View File

@@ -1,23 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsGenerateOptions(
string CurrentVersion,
string CurrentDirectory,
string Platform,
string OutputRoot,
string PreviousVersion = "0.0.0",
string? PreviousDirectory = null,
string Channel = "stable",
string? DistributionId = null,
string? RepoBaseUrl = null,
string? FileMapUrl = null,
string? FileMapSignatureUrl = null,
string? InstallerDirectory = null,
string? InstallerBaseUrl = null,
string IncrementalStrategy = "release-payload",
string? BaselineVersion = null,
string? BaselineRef = null,
string? SourceCommit = null,
bool IsFullPayloadRelease = false,
string? CommitRangeStart = null,
string? CommitRangeEnd = null);

View File

@@ -1,451 +0,0 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Plonds.Core.Publishing;
public sealed class PlondsGenerator
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public PlatformPublishResult Generate(PlondsGenerateOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var currentDirectory = Path.GetFullPath(options.CurrentDirectory);
if (!Directory.Exists(currentDirectory))
{
throw new DirectoryNotFoundException($"Current directory not found: {currentDirectory}");
}
var previousDirectory = string.IsNullOrWhiteSpace(options.PreviousDirectory)
? null
: Path.GetFullPath(options.PreviousDirectory);
var distributionId = string.IsNullOrWhiteSpace(options.DistributionId)
? $"plonds-{options.CurrentVersion}-{options.Platform}"
: options.DistributionId.Trim();
var outputRoot = Path.GetFullPath(options.OutputRoot);
var repoRoot = Path.Combine(outputRoot, "repo", "sha256");
var manifestsRoot = Path.Combine(outputRoot, "manifests", distributionId);
var metaDistributionRoot = Path.Combine(outputRoot, "meta", "distributions");
var metaChannelRoot = Path.Combine(outputRoot, "meta", "channels", options.Channel, options.Platform);
var installerMirrorRoot = Path.Combine(outputRoot, "installers", options.Platform, options.CurrentVersion);
Directory.CreateDirectory(repoRoot);
Directory.CreateDirectory(manifestsRoot);
Directory.CreateDirectory(metaDistributionRoot);
Directory.CreateDirectory(metaChannelRoot);
var previousManifest = options.IsFullPayloadRelease
? new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: ScanDirectory(previousDirectory);
var currentManifest = ScanDirectory(currentDirectory);
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
var publishedAt = DateTimeOffset.UtcNow;
var generatedAt = DateTimeOffset.UtcNow;
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
? options.PreviousVersion
: options.BaselineVersion;
var arch = ResolveArch(options.Platform);
var fileMap = new FileMapDocument(
FormatVersion: "2.0",
DistributionId: distributionId,
FromVersion: options.PreviousVersion,
ToVersion: options.CurrentVersion,
Version: options.CurrentVersion,
Platform: options.Platform,
Arch: arch,
Channel: options.Channel,
PublishedAt: publishedAt,
GeneratedAt: generatedAt,
BaselineVersion: baselineVersion,
Capabilities: ["file-object", "compressed-object"],
Components:
[
new ComponentDocument(
Id: "app",
Root: "/",
Mode: "file-object",
Files: fileEntries,
Metadata: new Dictionary<string, string> { ["component"] = "app" })
],
Metadata: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
["mode"] = "file-object",
["baselineVersion"] = baselineVersion,
["incrementalStrategy"] = options.IncrementalStrategy,
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
["sourceCommit"] = options.SourceCommit ?? string.Empty,
["baselineRef"] = options.BaselineRef ?? string.Empty,
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
});
var distribution = new DistributionDocument(
DistributionId: distributionId,
Version: options.CurrentVersion,
Channel: options.Channel,
Platform: options.Platform,
Arch: arch,
PublishedAt: publishedAt,
FileMapUrl: options.FileMapUrl,
FileMapSignatureUrl: options.FileMapSignatureUrl,
Components: fileMap.Components,
InstallerMirrors: installerMirrors,
Capabilities: ["file-object", "compressed-object"],
Metadata: new Dictionary<string, string>
{
["protocol"] = "PLONDS",
["baselineVersion"] = baselineVersion,
["incrementalStrategy"] = options.IncrementalStrategy,
["isFullPayloadRelease"] = options.IsFullPayloadRelease ? "true" : "false",
["sourceCommit"] = options.SourceCommit ?? string.Empty,
["baselineRef"] = options.BaselineRef ?? string.Empty,
["commitRangeStart"] = options.CommitRangeStart ?? string.Empty,
["commitRangeEnd"] = options.CommitRangeEnd ?? string.Empty
});
var latest = new LatestPointerDocument(
DistributionId: distributionId,
Version: options.CurrentVersion,
Channel: options.Channel,
Platform: options.Platform,
PublishedAt: publishedAt);
var fileMapPath = Path.Combine(manifestsRoot, "plonds-filemap.json");
var distributionPath = Path.Combine(metaDistributionRoot, distributionId + ".json");
var latestPath = Path.Combine(metaChannelRoot, "latest.json");
WriteJson(fileMapPath, fileMap);
WriteJson(distributionPath, distribution);
WriteJson(latestPath, latest);
return new PlatformPublishResult(
options.Platform,
distributionId,
currentDirectory,
previousDirectory,
options.PreviousVersion,
fileMapPath,
fileMapPath + ".sig",
distributionPath,
latestPath,
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
}
public static void WriteBundle(string fileMapPath, string signatureBase64)
{
var fileMapJson = File.ReadAllText(fileMapPath);
WriteBundle(fileMapPath, fileMapJson, signatureBase64);
}
private static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
{
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
return manifest;
}
var resolvedRoot = Path.GetFullPath(root);
foreach (var filePath in Directory.EnumerateFiles(resolvedRoot, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(resolvedRoot, filePath).Replace('\\', '/');
if (ShouldIgnore(relativePath))
{
continue;
}
var fileInfo = new FileInfo(filePath);
manifest[relativePath] = new FileFingerprint(relativePath, filePath, ComputeSha256(filePath), fileInfo.Length);
}
return manifest;
}
private static List<FileEntryDocument> BuildFileEntries(
Dictionary<string, FileFingerprint> previousManifest,
Dictionary<string, FileFingerprint> currentManifest,
string repoRoot,
string? repoBaseUrl)
{
var entries = new List<FileEntryDocument>();
foreach (var path in currentManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
var current = currentManifest[path];
if (previousManifest.TryGetValue(path, out var previous) &&
string.Equals(current.Sha256, previous.Sha256, StringComparison.OrdinalIgnoreCase))
{
entries.Add(new FileEntryDocument(
Path: path,
Action: "reuse",
Sha256: current.Sha256,
Size: current.Size,
Mode: "file-object",
ObjectKey: null,
ObjectUrl: null,
ArchiveSha256: null,
Metadata: new Dictionary<string, string> { ["reuseVerified"] = "true" }));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var (objectKey, archiveSha256, mode) = CopyContentObjectWithCompression(
current.FullPath, repoRoot, current.Sha256, current.Size);
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
? null
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
entries.Add(new FileEntryDocument(
Path: path,
Action: action,
Sha256: current.Sha256,
Size: current.Size,
Mode: mode,
ObjectKey: objectKey,
ObjectUrl: objectUrl,
ArchiveSha256: string.IsNullOrEmpty(archiveSha256) ? null : archiveSha256,
Metadata: new Dictionary<string, string> { ["mode"] = mode }));
}
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
{
if (!currentManifest.ContainsKey(path))
{
entries.Add(new FileEntryDocument(
Path: path,
Action: "delete",
Sha256: string.Empty,
Size: 0,
Mode: "file-object",
ObjectKey: null,
ObjectUrl: null,
ArchiveSha256: null,
Metadata: null));
}
}
return entries;
}
private static List<InstallerMirrorDocument> BuildInstallerMirrors(
string platform,
string installerMirrorRoot,
string? installerSourceDirectory,
string? installerBaseUrl)
{
var result = new List<InstallerMirrorDocument>();
if (string.IsNullOrWhiteSpace(installerSourceDirectory) || !Directory.Exists(installerSourceDirectory))
{
return result;
}
Directory.CreateDirectory(installerMirrorRoot);
foreach (var sourceFile in Directory.EnumerateFiles(installerSourceDirectory))
{
var fileName = Path.GetFileName(sourceFile);
var destinationPath = Path.Combine(installerMirrorRoot, fileName);
File.Copy(sourceFile, destinationPath, overwrite: true);
var url = string.IsNullOrWhiteSpace(installerBaseUrl)
? null
: $"{installerBaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}";
result.Add(new InstallerMirrorDocument(
Platform: platform,
Arch: ResolveArch(platform),
Url: url,
Name: fileName,
FileName: fileName,
Sha256: ComputeSha256(destinationPath),
Size: new FileInfo(destinationPath).Length));
}
return result;
}
private static string ResolveArch(string platform)
{
if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase))
{
return "x86";
}
if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase))
{
return "arm64";
}
return "x64";
}
private static bool ShouldIgnore(string relativePath)
{
var normalized = relativePath.Trim().Replace('\\', '/');
if (string.IsNullOrWhiteSpace(normalized))
{
return true;
}
return normalized.Equals(".current", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals(".partial", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals(".destroy", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith(".current/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith(".partial/", StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith(".destroy/", StringComparison.OrdinalIgnoreCase);
}
private static string CopyContentObject(string sourcePath, string repoRoot, string sha256)
{
var prefix = sha256[..Math.Min(2, sha256.Length)];
var relativeKey = $"{prefix}/{sha256}";
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (!File.Exists(destinationPath))
{
File.Copy(sourcePath, destinationPath, overwrite: true);
}
return relativeKey.Replace('\\', '/');
}
private static (string ObjectKey, string ArchiveSha256, string Mode) CopyContentObjectWithCompression(
string sourcePath, string repoRoot, string sha256, long fileSize)
{
if (fileSize > 65536)
{
var compressedBytes = CompressGzip(sourcePath);
var archiveSha256 = ComputeSha256FromBytes(compressedBytes);
var archiveKey = CopyBytesToObjectStore(compressedBytes, repoRoot, archiveSha256);
return (archiveKey, archiveSha256, "compressed-object");
}
var key = CopyContentObject(sourcePath, repoRoot, sha256);
return (key, string.Empty, "file-object");
}
private static byte[] CompressGzip(string filePath)
{
using var input = File.OpenRead(filePath);
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true))
{
input.CopyTo(gzip);
}
return output.ToArray();
}
private static string ComputeSha256FromBytes(byte[] data)
{
return Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
}
private static string CopyBytesToObjectStore(byte[] data, string repoRoot, string sha256)
{
var prefix = sha256[..Math.Min(2, sha256.Length)];
var relativeKey = $"{prefix}/{sha256}";
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (!File.Exists(destinationPath))
{
File.WriteAllBytes(destinationPath, data);
}
return relativeKey.Replace('\\', '/');
}
private static void WriteBundle(string fileMapPath, string fileMapJson, string signatureBase64)
{
var bundle = new BundleDocument(fileMapJson, signatureBase64);
WriteJson(fileMapPath + ".bundle.json", bundle);
}
private static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static void WriteJson<T>(string path, T value)
{
var json = JsonSerializer.Serialize(value, JsonOptions);
File.WriteAllText(path, json, new UTF8Encoding(false));
}
private sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size);
private sealed record FileMapDocument(
string FormatVersion,
string DistributionId,
string FromVersion,
string ToVersion,
string Version,
string Platform,
string Arch,
string Channel,
DateTimeOffset PublishedAt,
DateTimeOffset GeneratedAt,
string? BaselineVersion,
IReadOnlyList<string> Capabilities,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record DistributionDocument(
string DistributionId,
string Version,
string Channel,
string Platform,
string Arch,
DateTimeOffset PublishedAt,
string? FileMapUrl,
string? FileMapSignatureUrl,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyList<InstallerMirrorDocument> InstallerMirrors,
IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record LatestPointerDocument(
string DistributionId,
string Version,
string Channel,
string Platform,
DateTimeOffset PublishedAt);
private sealed record ComponentDocument(
string Id,
string Root,
string Mode,
IReadOnlyList<FileEntryDocument> Files,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record FileEntryDocument(
string Path,
string Action,
string Sha256,
long Size,
string Mode,
string? ObjectKey,
string? ObjectUrl,
string? ArchiveSha256,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record InstallerMirrorDocument(
string Platform,
string Arch,
string? Url,
string? Name,
string? FileName,
string? Sha256,
long Size);
private sealed record BundleDocument(string Manifest, string Signature);
}

View File

@@ -1,68 +0,0 @@
using Plonds.Core.Security;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsManifestBuilder
{
private readonly RsaFileSigner _signer = new();
public string Build(PlondsBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var assetsDirectory = Path.GetFullPath(options.AssetsDirectory);
if (!Directory.Exists(assetsDirectory))
{
throw new DirectoryNotFoundException($"PLONDS assets directory not found: {assetsDirectory}");
}
var assetEntries = Directory
.EnumerateFiles(assetsDirectory, "*", SearchOption.TopDirectoryOnly)
.Where(static path =>
{
var name = Path.GetFileName(path);
return !name.Equals("plonds.json", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("plonds.json.sig", StringComparison.OrdinalIgnoreCase);
})
.OrderBy(static path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)
.Select(path => BuildAssetEntry(path, options.Repository, options.ReleaseTag, options.S3BaseUrl))
.ToArray();
var manifest = new PlondsManifest(
FormatVersion: "1.0",
ReleaseTag: options.ReleaseTag,
GeneratedAt: DateTimeOffset.UtcNow,
Assets: assetEntries);
var outputRoot = Path.GetFullPath(options.OutputRoot);
Directory.CreateDirectory(outputRoot);
var manifestPath = Path.Combine(outputRoot, "plonds.json");
PayloadUtilities.WriteJson(manifestPath, manifest);
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
return manifestPath;
}
private static PlondsAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl)
{
var fileName = Path.GetFileName(assetPath);
var mirrors = new List<PlondsMirrorEntry>
{
new("github", $"https://github.com/{repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(fileName)}")
};
if (!string.IsNullOrWhiteSpace(s3BaseUrl))
{
mirrors.Add(new PlondsMirrorEntry(
"s3",
$"{s3BaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}"));
}
return new PlondsAssetEntry(
AssetId: fileName,
FileName: fileName,
Sha256: PayloadUtilities.ComputeSha256(assetPath),
Size: new FileInfo(assetPath).Length,
Mirrors: mirrors);
}
}

View File

@@ -1,19 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsPublishOptions(
string Version,
string AppArtifactsRoot,
string InstallerArtifactsRoot,
string OutputRoot,
string PrivateKeyPath,
string Channel = "stable",
string? BaselineRoot = null,
string? RepoBaseUrl = null,
string? InstallerBaseUrl = null,
string IncrementalStrategy = "release-payload",
string? BaselineVersion = null,
string? BaselineRef = null,
string? SourceCommit = null,
bool IsFullPayloadRelease = false,
string? CommitRangeStart = null,
string? CommitRangeEnd = null);

View File

@@ -1,237 +0,0 @@
using System.Text;
using System.Text.Json;
using Plonds.Core.Security;
using Plonds.Shared;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsPublisher
{
private static readonly PlatformConfig[] SupportedPlatforms =
[
new("windows-x64", "app-payload-windows-x64", [".exe"], ["x64"]),
new("windows-x86", "app-payload-windows-x86", [".exe"], ["x86"]),
new("linux-x64", "app-payload-linux-x64", [".deb"], ["linux", "x64"])
];
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private readonly PlondsGenerator _generator = new();
private readonly RsaFileSigner _signer = new();
public IReadOnlyList<PlatformPublishResult> Publish(PlondsPublishOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var results = new List<PlatformPublishResult>();
var releaseAssetsRoot = Path.Combine(Path.GetFullPath(options.OutputRoot), "release-assets");
Directory.CreateDirectory(releaseAssetsRoot);
foreach (var config in SupportedPlatforms)
{
var artifactRoot = Path.Combine(Path.GetFullPath(options.AppArtifactsRoot), config.ArtifactName);
if (!Directory.Exists(artifactRoot))
{
throw new DirectoryNotFoundException($"App payload artifact root not found for {config.Platform}: {artifactRoot}");
}
var currentAppDirectory = FindCurrentAppDirectory(artifactRoot, options.Version);
if (currentAppDirectory is null)
{
throw new DirectoryNotFoundException($"Unable to locate app payload directory for {config.Platform} under {artifactRoot}");
}
var baselineRoot = string.IsNullOrWhiteSpace(options.BaselineRoot)
? Path.Combine(Path.GetFullPath(options.OutputRoot), "_baselines")
: Path.GetFullPath(options.BaselineRoot);
var platformBaselineRoot = Path.Combine(baselineRoot, config.Platform);
var previousDirectory = Path.Combine(platformBaselineRoot, "current");
var previousVersionPath = Path.Combine(platformBaselineRoot, "version.txt");
Directory.CreateDirectory(platformBaselineRoot);
if (!Directory.Exists(previousDirectory))
{
Directory.CreateDirectory(previousDirectory);
}
var previousVersion = File.Exists(previousVersionPath)
? File.ReadAllText(previousVersionPath).Trim()
: "0.0.0";
var installerSourceDirectory = PrepareInstallerMirrorInput(
config,
options.InstallerArtifactsRoot,
Path.Combine(platformBaselineRoot, "installers"));
var distributionId = $"plonds-{options.Version}-{config.Platform}";
var repoBaseUrl = options.RepoBaseUrl;
var fileMapUrl = repoBaseUrl is null
? null
: $"{repoBaseUrl.TrimEnd('/').Replace("/repo/sha256", "/manifests")}/{distributionId}/plonds-filemap.json";
var fileMapSignatureUrl = fileMapUrl is null ? null : fileMapUrl + ".sig";
var installerBaseUrl = string.IsNullOrWhiteSpace(options.InstallerBaseUrl)
? null
: $"{options.InstallerBaseUrl.TrimEnd('/')}/{config.Platform}/{options.Version}";
var result = _generator.Generate(new PlondsGenerateOptions(
CurrentVersion: options.Version,
CurrentDirectory: currentAppDirectory,
Platform: config.Platform,
OutputRoot: options.OutputRoot,
PreviousVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
PreviousDirectory: previousDirectory,
Channel: options.Channel,
DistributionId: distributionId,
RepoBaseUrl: repoBaseUrl,
FileMapUrl: fileMapUrl,
FileMapSignatureUrl: fileMapSignatureUrl,
InstallerDirectory: installerSourceDirectory,
InstallerBaseUrl: installerBaseUrl,
IncrementalStrategy: options.IncrementalStrategy,
BaselineVersion: string.IsNullOrWhiteSpace(options.BaselineVersion) ? previousVersion : options.BaselineVersion,
BaselineRef: options.BaselineRef,
SourceCommit: options.SourceCommit,
IsFullPayloadRelease: options.IsFullPayloadRelease,
CommitRangeStart: options.CommitRangeStart,
CommitRangeEnd: options.CommitRangeEnd));
_signer.SignFile(result.FileMapPath, options.PrivateKeyPath, result.SignaturePath);
CopyReleaseAsset(result.FileMapPath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json"));
CopyReleaseAsset(result.SignaturePath, Path.Combine(releaseAssetsRoot, $"plonds-filemap-{config.Platform}.json.sig"));
CopyReleaseAsset(result.DistributionPath, Path.Combine(releaseAssetsRoot, $"plonds-distribution-{config.Platform}.json"));
CopyReleaseAsset(result.LatestPath, Path.Combine(releaseAssetsRoot, $"plonds-latest-{config.Platform}.json"));
MirrorBaseline(currentAppDirectory, previousDirectory, previousVersionPath, options.Version);
results.Add(result);
}
WriteMetadataCatalog(options, results);
return results;
}
private static void WriteMetadataCatalog(PlondsPublishOptions options, IReadOnlyList<PlatformPublishResult> results)
{
var outputRoot = Path.GetFullPath(options.OutputRoot);
var metadataRoot = Path.Combine(outputRoot, "meta");
Directory.CreateDirectory(metadataRoot);
var generatedAt = DateTimeOffset.UtcNow;
var latestPointers = results
.Select(result => new PlondsChannelPointer(
Channel: options.Channel,
Platform: result.Platform,
DistributionId: result.DistributionId,
Version: options.Version,
PublishedAt: generatedAt,
DistributionPath: $"distributions/{result.DistributionId}.json",
FileMapPath: $"../manifests/{result.DistributionId}/plonds-filemap.json"))
.OrderBy(pointer => pointer.Channel, StringComparer.OrdinalIgnoreCase)
.ThenBy(pointer => pointer.Platform, StringComparer.OrdinalIgnoreCase)
.ToArray();
var catalog = new PlondsMetadataCatalog(
ProtocolName: PlondsConstants.ProtocolName,
ProtocolVersion: PlondsConstants.ProtocolVersion,
StorageRoot: outputRoot,
MetaRoot: metadataRoot,
Latest: latestPointers,
Metadata: new Dictionary<string, string>
{
["generatedBy"] = "Plonds.Tool",
["channel"] = options.Channel,
["generatedAt"] = generatedAt.ToString("O")
});
var metadataPath = Path.Combine(metadataRoot, "metadata.json");
File.WriteAllText(metadataPath, JsonSerializer.Serialize(catalog, JsonOptions), new UTF8Encoding(false));
}
private static void MirrorBaseline(string currentAppDirectory, string previousDirectory, string previousVersionPath, string version)
{
if (Directory.Exists(previousDirectory))
{
Directory.Delete(previousDirectory, recursive: true);
}
CopyDirectory(currentAppDirectory, previousDirectory);
File.WriteAllText(previousVersionPath, version);
}
private static string? FindCurrentAppDirectory(string artifactRoot, string version)
{
var preferred = Directory.EnumerateDirectories(artifactRoot, $"app-{version}", SearchOption.AllDirectories).FirstOrDefault();
if (preferred is not null)
{
return preferred;
}
return Directory.EnumerateDirectories(artifactRoot, "app-*", SearchOption.AllDirectories)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
}
private static string PrepareInstallerMirrorInput(PlatformConfig config, string installerArtifactsRoot, string destinationRoot)
{
var installerFiles = FindInstallerFiles(config, installerArtifactsRoot);
if (Directory.Exists(destinationRoot))
{
Directory.Delete(destinationRoot, recursive: true);
}
Directory.CreateDirectory(destinationRoot);
foreach (var file in installerFiles)
{
File.Copy(file, Path.Combine(destinationRoot, Path.GetFileName(file)), overwrite: true);
}
return destinationRoot;
}
private static List<string> FindInstallerFiles(PlatformConfig config, string installerArtifactsRoot)
{
var files = Directory.EnumerateFiles(Path.GetFullPath(installerArtifactsRoot), "*", SearchOption.AllDirectories);
return files
.Where(file => config.InstallerExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase))
.Where(file =>
{
var fileName = Path.GetFileName(file);
return config.FileNameTokens.All(token => fileName.Contains(token, StringComparison.OrdinalIgnoreCase));
})
.ToList();
}
private static void CopyReleaseAsset(string sourcePath, string destinationPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(sourcePath, destinationPath, overwrite: true);
}
private static void CopyDirectory(string sourceDir, string destinationDir)
{
Directory.CreateDirectory(destinationDir);
foreach (var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDir, directory);
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
}
foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDir, file);
var destinationPath = Path.Combine(destinationDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(file, destinationPath, overwrite: true);
}
}
private sealed record PlatformConfig(
string Platform,
string ArtifactName,
IReadOnlyList<string> InstallerExtensions,
IReadOnlyList<string> FileNameTokens);
}

View File

@@ -1,57 +0,0 @@
using System.Text.Json;
using Plonds.Core.Security;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsReleaseIndexBuilder
{
private readonly RsaFileSigner _signer = new();
public string Build(PlondsReleaseIndexOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var summariesDirectory = Path.GetFullPath(options.PlatformSummariesDirectory);
if (!Directory.Exists(summariesDirectory))
{
throw new DirectoryNotFoundException($"Platform summary directory not found: {summariesDirectory}");
}
var summaries = Directory
.EnumerateFiles(summariesDirectory, "platform-summary-*.json", SearchOption.TopDirectoryOnly)
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(ReadSummary)
.OrderBy(static entry => entry.Platform, StringComparer.OrdinalIgnoreCase)
.ToArray();
var manifest = new PlondsReleaseManifest(
FormatVersion: "1.0",
ReleaseTag: options.ReleaseTag,
Version: options.Version,
Channel: options.Channel,
GeneratedAt: DateTimeOffset.UtcNow,
Platforms: summaries);
var outputRoot = Path.GetFullPath(options.OutputRoot);
var releaseAssetsRoot = Path.Combine(outputRoot, "release-assets");
Directory.CreateDirectory(releaseAssetsRoot);
var manifestPath = Path.Combine(releaseAssetsRoot, "plonds.json");
PayloadUtilities.WriteJson(manifestPath, manifest);
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
return manifestPath;
}
private static PlondsReleasePlatformEntry ReadSummary(string path)
{
var json = File.ReadAllText(path);
var summary = JsonSerializer.Deserialize<PlondsReleasePlatformEntry>(json, PayloadUtilities.JsonOptions);
if (summary is null)
{
throw new InvalidOperationException($"Unable to deserialize PLONDS platform summary: {path}");
}
return summary;
}
}

View File

@@ -1,9 +0,0 @@
namespace Plonds.Core.Publishing;
public sealed record PlondsReleaseIndexOptions(
string ReleaseTag,
string Version,
string Channel,
string PlatformSummariesDirectory,
string OutputRoot,
string PrivateKeyPath);

View File

@@ -1,38 +0,0 @@
using System.Security.Cryptography;
using System.Text;
namespace Plonds.Core.Security;
public sealed class RsaFileSigner
{
public string SignFile(string filePath, string privateKeyPath, string? outputPath = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException("Manifest file not found.", filePath);
}
if (!File.Exists(privateKeyPath))
{
throw new FileNotFoundException("Private key PEM file not found.", privateKeyPath);
}
outputPath ??= filePath + ".sig";
var payload = File.ReadAllBytes(filePath);
var privateKeyPem = File.ReadAllText(privateKeyPath, Encoding.ASCII);
if (string.IsNullOrWhiteSpace(privateKeyPem))
{
throw new InvalidOperationException("Private key PEM is empty.");
}
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
var signature = rsa.SignData(payload, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(outputPath, Convert.ToBase64String(signature), Encoding.ASCII);
return outputPath;
}
}

View File

@@ -1,8 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsAssetEntry(
string AssetId,
string FileName,
string Sha256,
long Size,
IReadOnlyList<PlondsMirrorEntry> Mirrors);

View File

@@ -0,0 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record PlondsChangedFileEntry(
string ArchivePath,
string Hash,
long Size,
string HashAlgorithm = "sha256");

View File

@@ -1,11 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsChannelPointer(
string Channel,
string Platform,
string DistributionId,
string Version,
DateTimeOffset PublishedAt,
string? DistributionPath = null,
string? FileMapPath = null);

View File

@@ -1,9 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsComponent(
string Id,
string Root,
string Mode,
IReadOnlyList<PlondsFileEntry> Files,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

@@ -1,14 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsDistributionInfo(
string DistributionId,
string Version,
string Channel,
string Platform,
DateTimeOffset PublishedAt,
IReadOnlyList<PlondsComponent> Components,
IReadOnlyList<PlondsMirrorAsset> InstallerMirrors,
IReadOnlyList<string> Capabilities,
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

@@ -1,13 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record PlondsFileEntry(
string Path,
string Op,
string ContentHash,
string Action,
string Hash,
long Size,
string Mode,
string? ObjectKey = null,
string? Compression = null,
string? PatchBaseHash = null,
string? PatchObjectKey = null);
string HashAlgorithm = "sha256");

View File

@@ -1,13 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsFileMap(
string FormatVersion,
string DistributionId,
string SourceVersion,
string TargetVersion,
string Platform,
IReadOnlyList<PlondsComponent> Components,
IReadOnlyList<string> Capabilities,
IReadOnlyList<PlondsSignatureDescriptor> Signatures,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

@@ -1,7 +1,19 @@
namespace Plonds.Shared.Models;
using System.Text.Json.Serialization;
namespace Plonds.Shared.Models;
public sealed record PlondsManifest(
string FormatVersion,
string ReleaseTag,
DateTimeOffset GeneratedAt,
IReadOnlyList<PlondsAssetEntry> Assets);
string CurrentVersion,
string PreviousVersion,
bool IsFullUpdate,
bool RequiresCleanInstall,
string Channel,
string Platform,
DateTimeOffset UpdatedAt,
string CompareMethod,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
string? HashAlgorithm,
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums);

View File

@@ -1,10 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsMetadataCatalog(
string ProtocolName,
string ProtocolVersion,
string StorageRoot,
string MetaRoot,
IReadOnlyList<PlondsChannelPointer> Latest,
IReadOnlyDictionary<string, string>? Metadata = null);

View File

@@ -1,9 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsMirrorAsset(
string Platform,
string Arch,
string Url,
string? FileName = null,
string? Sha256 = null,
long Size = 0);

View File

@@ -1,5 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsMirrorEntry(
string Type,
string Url);

View File

@@ -1,9 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsReleaseManifest(
string FormatVersion,
string ReleaseTag,
string Version,
string Channel,
DateTimeOffset GeneratedAt,
IReadOnlyList<PlondsReleasePlatformEntry> Platforms);

View File

@@ -1,14 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsReleasePlatformEntry(
string Platform,
string DistributionId,
string? BaselineTag,
string? BaselineVersion,
string TargetVersion,
bool IsFullPayload,
string FilesZipAsset,
string UpdateZipAsset,
string FileMapAsset,
string FileMapSignatureAsset,
string Sha256);

View File

@@ -1,7 +0,0 @@
namespace Plonds.Shared.Models;
public sealed record PlondsSignatureDescriptor(
string Algorithm,
string KeyId,
string Signature);

View File

@@ -3,23 +3,39 @@ namespace Plonds.Shared;
public static class PlondsConstants
{
public const string ProtocolName = "PLONDS";
public const string ProtocolVersion = "1.0";
public const string ProtocolVersion = "2.0";
public const string FormatVersion = "2.0";
public const string DefaultApiBasePath = "/api/plonds/v1";
public const string DefaultStorageRoot = "sample-data";
public const string DefaultMetaRoot = "meta";
public const string DefaultRepoRoot = "repo";
public const string DefaultInstallersRoot = "installers";
public const string ActionAdd = "add";
public const string ActionReplace = "replace";
public const string ActionReuse = "reuse";
public const string ActionDelete = "delete";
public const string FileObjectMode = "file-object";
public const string CompressedObjectMode = "compressed-object";
public const string BinaryPatchMode = "binary-patch";
public const string CompareMethodFileCompare = "file-compare";
public const string CompareMethodCommitAnalyze = "commit-analyze";
public static readonly string[] SupportedFileModes =
public const string HashAlgorithmSha256 = "sha256";
public const string HashAlgorithmMd5 = "md5";
public const string DefaultLauncherRelativePath = "LanMountainDesktop.Launcher.exe";
public static readonly string[] SupportedActions =
[
FileObjectMode,
CompressedObjectMode,
BinaryPatchMode
ActionAdd,
ActionReplace,
ActionReuse,
ActionDelete
];
public static readonly string[] SupportedHashAlgorithms =
[
HashAlgorithmSha256,
HashAlgorithmMd5
];
public static readonly string[] SupportedCompareMethods =
[
CompareMethodFileCompare,
CompareMethodCommitAnalyze
];
}

View File

@@ -0,0 +1,9 @@
namespace Plonds.Shared;
public enum PlondsFileAction
{
Add,
Replace,
Reuse,
Delete
}

View File

@@ -1,5 +1,4 @@
using Plonds.Core.Publishing;
using Plonds.Core.Security;
using Plonds.Core.Publishing;
return await PlondsCli.RunAsync(args);
@@ -20,26 +19,14 @@ internal static class PlondsCli
{
switch (command)
{
case "generate":
RunGenerate(options);
return Task.FromResult(0);
case "sign":
RunSign(options);
return Task.FromResult(0);
case "publish":
RunPublish(options);
return Task.FromResult(0);
case "pack-payload":
RunPackPayload(options);
return Task.FromResult(0);
case "build-delta":
RunBuildDelta(options);
return Task.FromResult(0);
case "build-index":
RunBuildIndex(options);
case "build-delta-from-commits":
RunBuildDeltaFromCommits(options);
return Task.FromResult(0);
case "build-plonds":
RunBuildPlonds(options);
case "pack-payload":
RunPackPayload(options);
return Task.FromResult(0);
default:
Console.Error.WriteLine($"Unknown command: {command}");
@@ -54,63 +41,51 @@ internal static class PlondsCli
}
}
private static void RunGenerate(Dictionary<string, string> options)
private static void RunBuildDelta(Dictionary<string, string> options)
{
var generator = new PlondsGenerator();
var result = generator.Generate(new PlondsGenerateOptions(
CurrentVersion: Require(options, "current-version"),
CurrentDirectory: Require(options, "current-dir"),
var builder = new PlondsDeltaBuilder();
var result = builder.Build(new PlondsDeltaBuildOptions(
Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"),
PreviousVersion: Get(options, "previous-version", "0.0.0") ?? "0.0.0",
PreviousDirectory: Get(options, "previous-dir"),
Channel: Get(options, "channel", "stable") ?? "stable",
DistributionId: Get(options, "distribution-id"),
RepoBaseUrl: Get(options, "repo-base-url"),
FileMapUrl: Get(options, "file-map-url"),
FileMapSignatureUrl: Get(options, "file-map-signature-url"),
InstallerDirectory: Get(options, "installer-directory"),
InstallerBaseUrl: Get(options, "installer-base-url")));
Console.WriteLine($"Generated PLONDS artifacts for {result.Platform}: {result.DistributionId}");
Console.WriteLine(result.FileMapPath);
}
private static void RunSign(Dictionary<string, string> options)
{
var signer = new RsaFileSigner();
var signaturePath = signer.SignFile(
Require(options, "manifest"),
Require(options, "private-key"),
Get(options, "output"));
Console.WriteLine(signaturePath);
}
private static void RunPublish(Dictionary<string, string> options)
{
var publisher = new PlondsPublisher();
var results = publisher.Publish(new PlondsPublishOptions(
Version: Require(options, "version"),
AppArtifactsRoot: Require(options, "app-artifacts-root"),
InstallerArtifactsRoot: Require(options, "installer-artifacts-root"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Channel: Get(options, "channel", "stable") ?? "stable",
BaselineRoot: Get(options, "baseline-root"),
RepoBaseUrl: Get(options, "repo-base-url"),
InstallerBaseUrl: Get(options, "installer-base-url"),
IncrementalStrategy: Get(options, "incremental-strategy", "release-payload") ?? "release-payload",
BaselineVersion: Get(options, "baseline-version"),
BaselineRef: Get(options, "baseline-ref"),
SourceCommit: Get(options, "source-commit"),
IsFullPayloadRelease: bool.TryParse(Get(options, "is-full-payload-release", "false"), out var isFullPayloadRelease) && isFullPayloadRelease,
CommitRangeStart: Get(options, "commit-range-start"),
CommitRangeEnd: Get(options, "commit-range-end")));
BaselinePayloadZip: Get(options, "baseline-zip"),
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
foreach (var result in results)
{
Console.WriteLine($"{result.Platform}: {result.DistributionId}");
}
Console.WriteLine($"Built PLONDS delta for {result.Platform}:");
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
Console.WriteLine($" Manifest: {result.ManifestPath}");
}
private static void RunBuildDeltaFromCommits(Dictionary<string, string> options)
{
var builder = new PlondsCommitDeltaBuilder();
var result = builder.Build(new PlondsCommitDeltaBuildOptions(
Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"),
Channel: Require(options, "channel"),
BaselineTag: Require(options, "baseline-tag"),
CurrentTag: Require(options, "current-tag"),
FallbackBaselineZip: Get(options, "fallback-zip"),
BaselineVersion: Get(options, "baseline-version"),
LauncherRelativePath: Get(options, "launcher-path", "LanMountainDesktop.Launcher.exe") ?? "LanMountainDesktop.Launcher.exe",
HashAlgorithm: Get(options, "hash-algorithm", "sha256") ?? "sha256"));
Console.WriteLine($"Built PLONDS commit-delta for {result.Platform}:");
Console.WriteLine($" IsFullUpdate: {result.IsFullUpdate}");
Console.WriteLine($" RequiresCleanInstall: {result.RequiresCleanInstall}");
Console.WriteLine($" FellBackToFileCompare: {result.FellBackToFileCompare}");
Console.WriteLine($" ChangedSourceFiles: {result.ChangedSourceFiles.Count}");
Console.WriteLine($" MappedArtifactFiles: {result.MappedArtifactFiles.Count}");
Console.WriteLine($" ChangedZip: {result.ChangedZipPath}");
Console.WriteLine($" Manifest: {result.ManifestPath}");
}
private static void RunPackPayload(Dictionary<string, string> options)
@@ -121,56 +96,6 @@ internal static class PlondsCli
Console.WriteLine(outputZip);
}
private static void RunBuildDelta(Dictionary<string, string> options)
{
var builder = new PlondsDeltaBuilder();
var result = builder.Build(new PlondsDeltaBuildOptions(
Platform: Require(options, "platform"),
CurrentVersion: Require(options, "current-version"),
CurrentTag: Require(options, "current-tag"),
CurrentPayloadZip: Require(options, "current-zip"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Channel: Get(options, "channel", "stable") ?? "stable",
BaselineVersion: Get(options, "baseline-version"),
BaselineTag: Get(options, "baseline-tag"),
BaselinePayloadZip: Get(options, "baseline-zip"),
IsFullPayload: bool.TryParse(Get(options, "is-full-payload", "false"), out var isFullPayload) && isFullPayload,
StaticOutputRoot: Get(options, "static-output-dir"),
UpdateBaseUrl: Get(options, "update-base-url")));
Console.WriteLine($"Built PLONDS delta for {result.Platform}: {result.UpdateArchivePath}");
Console.WriteLine(result.FileMapPath);
}
private static void RunBuildIndex(Dictionary<string, string> options)
{
var builder = new PlondsReleaseIndexBuilder();
var manifestPath = builder.Build(new PlondsReleaseIndexOptions(
ReleaseTag: Require(options, "release-tag"),
Version: Require(options, "version"),
Channel: Get(options, "channel", "stable") ?? "stable",
PlatformSummariesDirectory: Require(options, "platform-summaries-dir"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key")));
Console.WriteLine(manifestPath);
}
private static void RunBuildPlonds(Dictionary<string, string> options)
{
var builder = new PlondsManifestBuilder();
var manifestPath = builder.Build(new PlondsBuildOptions(
ReleaseTag: Require(options, "release-tag"),
AssetsDirectory: Require(options, "assets-dir"),
OutputRoot: Require(options, "output-dir"),
PrivateKeyPath: Require(options, "private-key"),
Repository: Require(options, "repository"),
S3BaseUrl: Get(options, "s3-base-url")));
Console.WriteLine(manifestPath);
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -212,12 +137,8 @@ internal static class PlondsCli
private static void PrintUsage()
{
Console.WriteLine("PLONDS Tool");
Console.WriteLine(" build-delta --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> [--channel <ch>] [--baseline-version <v>] [--baseline-zip <file>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
Console.WriteLine(" build-delta-from-commits --platform <p> --current-version <v> --current-zip <file> --output-dir <dir> --channel <ch> --baseline-tag <tag> --current-tag <tag> [--fallback-zip <file>] [--baseline-version <v>] [--launcher-path <path>] [--hash-algorithm sha256|md5]");
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload] [--static-output-dir <dir>] [--update-base-url <url>]");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
Console.WriteLine(" build-plonds --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");
}
}