Compare commits

..

10 Commits

Author SHA1 Message Date
lincube
631dc7795a Normalize release artifacts before publishing 2026-04-21 21:17:52 +08:00
lincube
001a42a97f Fix Windows installer script path in release workflow 2026-04-21 20:18:12 +08:00
lincube
8a75bc818a Rebuild release pipeline around PLONDS and DDSS 2026-04-21 19:26:59 +08:00
lincube
8568fdf16b ci.plonds 2026-04-21 16:12:47 +08:00
lincube
d31aa90b9c ci: handle empty plonds baselines safely 2026-04-21 08:39:07 +08:00
lincube
0878bcab5a ci: avoid multipart uploads to rainyun s3 2026-04-21 08:27:06 +08:00
lincube
4d5bea0c46 ci: relax aws checksum mode for rainyun s3 2026-04-21 08:20:41 +08:00
lincube
8323b8cb61 ci: validate signing key and quiet missing baselines 2026-04-21 00:46:57 +08:00
lincube
82f1e77393 ci: fix plonds s3 probe and signing fallback 2026-04-21 00:11:17 +08:00
lincube
a31ae3cd58 feat.Penguin Logistics Online Network Distribution System 2026-04-20 23:28:11 +08:00
64 changed files with 5171 additions and 1724 deletions

166
.github/workflows/ddss-publish.yml vendored Normal file
View File

@@ -0,0 +1,166 @@
name: DDSS
on:
workflow_run:
workflows:
- PLONDS
types:
- completed
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
env:
DOTNET_VERSION: '10.0.x'
jobs:
publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "$RAW_TAG" == v* ]]; then
TAG="$RAW_TAG"
else
TAG="v$RAW_TAG"
fi
else
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
fi
printf '%s' "$KEY" > update-private-key.pem
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p release-assets
gh release download "$RELEASE_TAG" -D release-assets
find release-assets -maxdepth 1 -type f | sort
- name: Upload release assets to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
shell: bash
run: |
set -euo pipefail
aws --version
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
continue
fi
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
if [[ "$existing_sha" == "$sha256" ]]; then
echo "Skip existing asset: $name"
continue
fi
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--metadata "sha256=$sha256"
done
- name: Build DDSS manifest
shell: bash
run: |
set -euo pipefail
mkdir -p ddss-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-ddss \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir ddss-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
- name: Upload DDSS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
- name: Upload DDSS manifest to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
shell: bash
run: |
set -euo pipefail
for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
name="$(basename "$file")"
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--metadata "sha256=$sha256"
done

235
.github/workflows/plonds-build.yml vendored Normal file
View File

@@ -0,0 +1,235 @@
name: PLONDS
on:
release:
types:
- published
- prereleased
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
baseline_tag:
description: 'Optional baseline tag'
required: false
type: string
channel:
description: 'Update channel'
required: false
type: choice
default: stable
options:
- stable
- preview
env:
DOTNET_VERSION: '10.0.x'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release context
shell: bash
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
TAG="${{ github.event.release.tag_name }}"
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
CHANNEL="preview"
else
CHANNEL="stable"
fi
BASELINE_TAG=""
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHANNEL="${{ github.event.inputs.channel }}"
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
fi
printf '%s' "$KEY" > update-private-key.pem
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Resolve baseline plan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$tag = $env:RELEASE_TAG
$baselineInput = $env:BASELINE_TAG_INPUT
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
$entries = foreach ($platform in $platforms) {
$assetName = "files-$platform.zip"
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
if (-not $currentAsset) {
throw "Current release $tag does not contain required asset $assetName"
}
$baselineRelease = $null
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
if (-not $baselineRelease) {
throw "Specified baseline tag not found: $normalizedBaseline"
}
}
else {
$baselineRelease = $allReleases |
Where-Object {
$_.tag_name -ne $tag -and
-not $_.draft -and
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
} |
Select-Object -First 1
}
[pscustomobject]@{
platform = $platform
assetName = $assetName
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
isFullPayload = -not $baselineRelease
}
}
$plan = [pscustomobject]@{
tag = $tag
version = $env:RELEASE_VERSION
channel = $env:RELEASE_CHANNEL
platforms = $entries
}
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
Get-Content plonds-plan.json
- name: Download payload zips
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
}
}
- name: Build delta assets
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
$args = @(
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
'build-delta',
'--platform', $entry.platform,
'--current-version', $plan.version,
'--current-tag', $plan.tag,
'--current-zip', $currentZip,
'--output-dir', 'plonds-output',
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
'--channel', $plan.channel
)
if ([bool]$entry.isFullPayload) {
$args += @('--is-full-payload', 'true')
}
else {
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
}
dotnet @args
}
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
build-index `
--release-tag $plan.tag `
--version $plan.version `
--channel $plan.channel `
--platform-summaries-dir plonds-output/platform-summaries `
--output-dir plonds-output `
--private-key $env:UPDATE_PRIVATE_KEY_PATH
- name: Upload PLONDS assets to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
- name: Persist run metadata
shell: bash
run: |
mkdir -p plonds-run-metadata
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
- name: Upload run metadata artifact
uses: actions/upload-artifact@v4
with:
name: plonds-run-metadata
path: plonds-run-metadata/tag.txt
if-no-files-found: error
retention-days: 7

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,11 @@ namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(PdcUpdateMetadata))]
[JsonSerializable(typeof(PdcFileMap))]
[JsonSerializable(typeof(PdcComponentEntry))]
[JsonSerializable(typeof(PdcFileEntry))]
[JsonSerializable(typeof(PdcHashDescriptor))]
[JsonSerializable(typeof(PlondsUpdateMetadata))]
[JsonSerializable(typeof(PlondsFileMap))]
[JsonSerializable(typeof(PlondsComponentEntry))]
[JsonSerializable(typeof(PlondsFileEntry))]
[JsonSerializable(typeof(PlondsHashDescriptor))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]

View File

@@ -54,7 +54,7 @@ internal sealed class UpdateApplyResult
public string? RolledBackTo { get; init; }
}
internal sealed class PdcUpdateMetadata
internal sealed class PlondsUpdateMetadata
{
public string? DistributionId { get; set; }
@@ -73,7 +73,7 @@ internal sealed class PdcUpdateMetadata
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PdcFileMap
internal sealed class PlondsFileMap
{
public string? DistributionId { get; set; }
@@ -89,12 +89,12 @@ internal sealed class PdcFileMap
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PdcComponentEntry> Components { get; set; } = [];
public List<PlondsComponentEntry> Components { get; set; } = [];
public List<PdcFileEntry> Files { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PdcComponentEntry
internal sealed class PlondsComponentEntry
{
public string Name { get; set; } = string.Empty;
@@ -102,10 +102,10 @@ internal sealed class PdcComponentEntry
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PdcFileEntry> Files { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PdcFileEntry
internal sealed class PlondsFileEntry
{
public string Path { get; set; } = string.Empty;
@@ -129,12 +129,12 @@ internal sealed class PdcFileEntry
public byte[]? Sha512Bytes { get; set; }
public PdcHashDescriptor? Hash { get; set; }
public PlondsHashDescriptor? Hash { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PdcHashDescriptor
internal sealed class PlondsHashDescriptor
{
public string? Algorithm { get; set; }

View File

@@ -14,10 +14,10 @@ internal sealed class UpdateEngineService
private const string SignedFileMapName = "files.json";
private const string SignatureFileName = "files.json.sig";
private const string ArchiveFileName = "update.zip";
private const string PdcFileMapName = "pdc-filemap.json";
private const string PdcSignatureFileName = "pdc-filemap.sig";
private const string PdcUpdateMetadataName = "pdc-update.json";
private const string PdcObjectsDirectoryName = "objects";
private const string PlondsFileMapName = "plonds-filemap.json";
private const string PlondsSignatureFileName = "plonds-filemap.sig";
private const string PlondsUpdateMetadataName = "plonds-update.json";
private const string PlondsObjectsDirectoryName = "objects";
private const string PublicKeyFileName = "public-key.pem";
private readonly DeploymentLocator _deploymentLocator;
@@ -37,33 +37,33 @@ internal sealed class UpdateEngineService
public LauncherResult CheckPendingUpdate()
{
var pdcFileMapPath = Path.Combine(_incomingRoot, PdcFileMapName);
var pdcSignaturePath = Path.Combine(_incomingRoot, PdcSignatureFileName);
var pdcUpdatePath = Path.Combine(_incomingRoot, PdcUpdateMetadataName);
var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName);
var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName);
var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName);
if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
{
var pdcFileMapText = File.ReadAllText(pdcFileMapPath);
var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PdcFileMap);
var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PlondsFileMap);
if (pdcFileMap is null)
{
return Failed("update.check", "invalid_manifest", "pdc-filemap.json is invalid.");
return Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid.");
}
var pdcVerified = VerifySignature(pdcFileMapPath, pdcSignaturePath, PdcSignatureFileName);
var pdcVerified = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName);
if (!pdcVerified.Success)
{
return Failed("update.check", "signature_failed", pdcVerified.Message);
}
var pdcMetadata = LoadPdcUpdateMetadata(pdcUpdatePath);
var pdcMetadata = LoadPlondsUpdateMetadata(pdcUpdatePath);
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "available",
Message = "Pending PDC update is available.",
Message = "Pending PLONDS update is available.",
CurrentVersion = _deploymentLocator.GetCurrentVersion(),
TargetVersion = ResolvePdcTargetVersion(pdcFileMap, pdcMetadata)
TargetVersion = ResolvePlondsTargetVersion(pdcFileMap, pdcMetadata)
};
}
@@ -126,12 +126,12 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(_incomingRoot);
Directory.CreateDirectory(_snapshotsRoot);
var pdcFileMapPath = Path.Combine(_incomingRoot, PdcFileMapName);
var pdcSignaturePath = Path.Combine(_incomingRoot, PdcSignatureFileName);
var pdcUpdatePath = Path.Combine(_incomingRoot, PdcUpdateMetadataName);
var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName);
var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName);
var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName);
if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
{
return await ApplyPendingPdcUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath);
return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath);
}
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
@@ -165,9 +165,7 @@ internal sealed class UpdateEngineService
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
// 全新安装场景:没有当前部署目录,但有更新包
// 这种情况下应该直接应用更新作为首次安装
return await ApplyInitialDeploymentAsync(fileMap, archivePath, fileMapPath, signaturePath);
// Initial install path: no current deployment exists, so apply the staged package directly.
}
var currentVersion = _deploymentLocator.GetCurrentVersion();
@@ -236,7 +234,7 @@ internal sealed class UpdateEngineService
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
// 清理旧版本但保留最近3个版本以支持回滚
// 婵炴挸鎳愰幃濠囧籍瑜忔晶妤呭嫉椤掑﹦绀夊ù锝呮缁绘岸鎮惧▎鎰粯閺?濞戞搩浜炴晶妤呭嫉椤戝じ绨伴柡鈧娑樼槷闁搞儳鍋炵划?
CleanupDestroyedDeployments();
return new LauncherResult
@@ -280,46 +278,46 @@ internal sealed class UpdateEngineService
}
}
private async Task<LauncherResult> ApplyPendingPdcUpdateAsync(
private async Task<LauncherResult> ApplyPendingPlondsUpdateAsync(
string pdcFileMapPath,
string pdcSignaturePath,
string pdcUpdatePath)
{
var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PdcSignatureFileName);
var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName);
if (!verifyResult.Success)
{
return Failed("update.apply", "signature_failed", verifyResult.Message);
}
var fileMapText = await File.ReadAllTextAsync(pdcFileMapPath).ConfigureAwait(false);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PdcFileMap) ?? new PdcFileMap();
var fileEntries = CollectPdcFileEntries(fileMap);
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap();
var fileEntries = CollectPlondsFileEntries(fileMap);
if (fileEntries.Count == 0)
{
PopulatePdcManifestFromRawJson(fileMapText, fileMap, fileEntries);
PopulatePlondsManifestFromRawJson(fileMapText, fileMap, fileEntries);
}
if (fileEntries.Count == 0)
{
return Failed("update.apply", "invalid_manifest", "No PDC file entries were found.");
return Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
}
var pdcMetadata = LoadPdcUpdateMetadata(pdcUpdatePath);
var pdcMetadata = LoadPlondsUpdateMetadata(pdcUpdatePath);
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
var currentVersion = _deploymentLocator.GetCurrentVersion();
var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion;
var expectedSourceVersion = ResolvePdcSourceVersion(fileMap, pdcMetadata);
var expectedSourceVersion = ResolvePlondsSourceVersion(fileMap, pdcMetadata);
if (!string.IsNullOrWhiteSpace(expectedSourceVersion) &&
!string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase))
{
return Failed(
"update.apply",
"version_mismatch",
$"PDC update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
$"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}.");
}
var targetVersion = ResolvePdcTargetVersion(fileMap, pdcMetadata);
var targetVersion = ResolvePlondsTargetVersion(fileMap, pdcMetadata);
if (string.IsNullOrWhiteSpace(targetVersion))
{
targetVersion = sourceVersion;
@@ -354,12 +352,12 @@ internal sealed class UpdateEngineService
foreach (var entry in fileEntries)
{
ApplyPdcFileEntry(entry, currentDeployment, targetDeployment);
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
}
foreach (var entry in fileEntries)
{
VerifyPdcFileEntry(entry, targetDeployment);
VerifyPlondsFileEntry(entry, targetDeployment);
}
if (isInitialDeployment)
@@ -412,7 +410,7 @@ internal sealed class UpdateEngineService
Success = false,
Stage = "update.apply",
Code = "initial_deploy_failed",
Message = "Failed to apply initial PDC deployment.",
Message = "Failed to apply initial PLONDS deployment.",
ErrorMessage = ex.Message,
CurrentVersion = "0.0.0",
TargetVersion = targetVersion
@@ -427,7 +425,7 @@ internal sealed class UpdateEngineService
Success = false,
Stage = "update.apply",
Code = "apply_failed",
Message = "Failed to apply PDC update. Rolled back to previous version.",
Message = "Failed to apply PLONDS update. Rolled back to previous version.",
ErrorMessage = ex.Message,
CurrentVersion = sourceVersion,
RolledBackTo = sourceVersion
@@ -435,7 +433,7 @@ internal sealed class UpdateEngineService
}
}
private void ApplyPdcFileEntry(PdcFileEntry file, string? currentDeployment, string targetDeployment)
private void ApplyPlondsFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment)
{
var normalizedPath = NormalizeRelativePath(file.Path);
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
@@ -467,16 +465,18 @@ internal sealed class UpdateEngineService
}
File.Copy(sourcePath, targetPath, overwrite: true);
ApplyUnixFileModeIfPresent(targetPath, file);
return;
}
var objectPath = ResolvePdcObjectPath(file);
var objectPath = ResolvePlondsObjectPath(file);
var objectBytes = File.ReadAllBytes(objectPath);
var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes;
File.WriteAllBytes(targetPath, restoredBytes);
ApplyUnixFileModeIfPresent(targetPath, file);
}
private void VerifyPdcFileEntry(PdcFileEntry file, string targetDeployment)
private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment)
{
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase))
@@ -512,26 +512,26 @@ internal sealed class UpdateEngineService
}
}
private string ResolvePdcObjectPath(PdcFileEntry file)
private string ResolvePlondsObjectPath(PlondsFileEntry file)
{
var candidates = new List<string>();
AddPdcPathCandidates(candidates, file.ObjectPath);
AddPdcPathCandidates(candidates, file.ObjectKey);
AddPdcPathCandidates(candidates, file.ArchivePath);
AddPdcPathCandidates(candidates, file.ObjectUrl);
AddPdcPathCandidates(candidates, file.Url);
AddPlondsPathCandidates(candidates, file.ObjectPath);
AddPlondsPathCandidates(candidates, file.ObjectKey);
AddPlondsPathCandidates(candidates, file.ArchivePath);
AddPlondsPathCandidates(candidates, file.ObjectUrl);
AddPlondsPathCandidates(candidates, file.Url);
if (TryGetExpectedObjectSha512(file, out var expectedSha512) || TryGetExpectedSha512(file, out expectedSha512))
{
var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant();
AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex));
AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex));
if (hashHex.Length > 2)
{
AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex[..2], hashHex));
AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex[..2], hashHex));
// Backward compatibility for previously staged paths.
AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex[..2], hashHex[2..]));
AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..]));
}
AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, $"{hashHex}.gz"));
AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, $"{hashHex}.gz"));
}
foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
@@ -567,7 +567,7 @@ internal sealed class UpdateEngineService
}
}
private void AddPdcPathCandidates(ICollection<string> candidates, string? value)
private void AddPlondsPathCandidates(ICollection<string> candidates, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -589,19 +589,19 @@ internal sealed class UpdateEngineService
normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
candidates.Add(normalized);
if (!normalized.StartsWith($"{PdcObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
if (!normalized.StartsWith($"{PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
candidates.Add(Path.Combine(PdcObjectsDirectoryName, normalized));
candidates.Add(Path.Combine(PlondsObjectsDirectoryName, normalized));
}
var fileName = Path.GetFileName(normalized);
if (!string.IsNullOrWhiteSpace(fileName))
{
candidates.Add(Path.Combine(PdcObjectsDirectoryName, fileName));
candidates.Add(Path.Combine(PlondsObjectsDirectoryName, fileName));
}
}
private static bool TryGetExpectedSha512(PdcFileEntry file, out byte[] expected)
private static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected)
{
expected = [];
if (file.Sha512Bytes is { Length: > 0 })
@@ -636,7 +636,7 @@ internal sealed class UpdateEngineService
return TryParseHashBytes(file.Sha512Base64, out expected);
}
private static bool TryGetExpectedObjectSha512(PdcFileEntry file, out byte[] expected)
private static bool TryGetExpectedObjectSha512(PlondsFileEntry file, out byte[] expected)
{
expected = [];
if (file.Hash is null)
@@ -724,9 +724,9 @@ internal sealed class UpdateEngineService
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
}
private static List<PdcFileEntry> CollectPdcFileEntries(PdcFileMap fileMap)
private static List<PlondsFileEntry> CollectPlondsFileEntries(PlondsFileMap fileMap)
{
var files = new List<PdcFileEntry>();
var files = new List<PlondsFileEntry>();
if (fileMap.Files is { Count: > 0 })
{
files.AddRange(fileMap.Files);
@@ -748,7 +748,7 @@ internal sealed class UpdateEngineService
return files;
}
private static void PopulatePdcManifestFromRawJson(string fileMapJson, PdcFileMap fileMap, ICollection<PdcFileEntry> files)
private static void PopulatePlondsManifestFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection<PlondsFileEntry> files)
{
if (string.IsNullOrWhiteSpace(fileMapJson))
{
@@ -794,7 +794,7 @@ internal sealed class UpdateEngineService
if (TryGetJsonPropertyIgnoreCase(root, "files", out var rootFilesNode))
{
ParsePdcFilesNode(rootFilesNode, null, files);
ParsePlondsFilesNode(rootFilesNode, null, files);
}
if (!TryGetJsonPropertyIgnoreCase(root, "components", out var componentsNode))
@@ -813,7 +813,7 @@ internal sealed class UpdateEngineService
if (TryGetJsonPropertyIgnoreCase(component.Value, "files", out var componentFilesNode))
{
ParsePdcFilesNode(componentFilesNode, component.Name, files);
ParsePlondsFilesNode(componentFilesNode, component.Name, files);
}
}
@@ -835,12 +835,12 @@ internal sealed class UpdateEngineService
var componentName = ReadJsonStringIgnoreCase(component, "name");
if (TryGetJsonPropertyIgnoreCase(component, "files", out var componentFilesNode))
{
ParsePdcFilesNode(componentFilesNode, componentName, files);
ParsePlondsFilesNode(componentFilesNode, componentName, files);
}
}
}
private static void ParsePdcFilesNode(JsonElement filesNode, string? componentName, ICollection<PdcFileEntry> files)
private static void ParsePlondsFilesNode(JsonElement filesNode, string? componentName, ICollection<PlondsFileEntry> files)
{
if (filesNode.ValueKind == JsonValueKind.Object)
{
@@ -851,7 +851,7 @@ internal sealed class UpdateEngineService
continue;
}
if (TryCreatePdcFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed))
if (TryCreatePlondsFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed))
{
files.Add(parsed);
}
@@ -873,16 +873,16 @@ internal sealed class UpdateEngineService
}
var fallbackPath = ReadJsonStringIgnoreCase(fileEntry, "path");
if (TryCreatePdcFileEntry(fallbackPath, componentName, fileEntry, out var parsed))
if (TryCreatePlondsFileEntry(fallbackPath, componentName, fileEntry, out var parsed))
{
files.Add(parsed);
}
}
}
private static bool TryCreatePdcFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PdcFileEntry entry)
private static bool TryCreatePlondsFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry)
{
entry = new PdcFileEntry();
entry = new PlondsFileEntry();
var path = ReadJsonStringIgnoreCase(node, "path");
if (string.IsNullOrWhiteSpace(path))
{
@@ -916,7 +916,30 @@ internal sealed class UpdateEngineService
metadata["component"] = componentName;
}
entry = new PdcFileEntry
if (TryGetJsonPropertyIgnoreCase(node, "metadata", out var metadataNode) &&
metadataNode.ValueKind == JsonValueKind.Object)
{
foreach (var property in metadataNode.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.Null ||
property.Value.ValueKind == JsonValueKind.Undefined)
{
continue;
}
var value = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString()
: property.Value.ToString();
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
metadata[property.Name] = value;
}
}
entry = new PlondsFileEntry
{
Path = path,
Action = string.IsNullOrWhiteSpace(action) ? "replace" : action,
@@ -934,7 +957,7 @@ internal sealed class UpdateEngineService
if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text))
{
entry.Hash = new PdcHashDescriptor
entry.Hash = new PlondsHashDescriptor
{
Algorithm = "sha512",
Bytes = archiveSha512,
@@ -945,7 +968,7 @@ internal sealed class UpdateEngineService
}
else if (TryGetJsonPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object)
{
entry.Hash = new PdcHashDescriptor
entry.Hash = new PlondsHashDescriptor
{
Algorithm = ReadJsonStringIgnoreCase(hashNode, "algorithm"),
Value = ReadJsonStringIgnoreCase(hashNode, "value"),
@@ -956,6 +979,31 @@ internal sealed class UpdateEngineService
return true;
}
private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file)
{
if (OperatingSystem.IsWindows())
{
return;
}
if (!file.Metadata.TryGetValue("unixFileMode", out var rawMode) ||
string.IsNullOrWhiteSpace(rawMode))
{
return;
}
try
{
var normalized = rawMode.Trim();
var modeValue = Convert.ToInt32(normalized, 8);
File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue);
}
catch
{
// Best-effort only. A bad mode should not break the entire update.
}
}
private static bool TryGetJsonPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{
if (node.ValueKind == JsonValueKind.Object)
@@ -1028,7 +1076,7 @@ internal sealed class UpdateEngineService
}
}
private static PdcUpdateMetadata? LoadPdcUpdateMetadata(string path)
private static PlondsUpdateMetadata? LoadPlondsUpdateMetadata(string path)
{
if (!File.Exists(path))
{
@@ -1043,7 +1091,7 @@ internal sealed class UpdateEngineService
return null;
}
return JsonSerializer.Deserialize(text, AppJsonContext.Default.PdcUpdateMetadata);
return JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata);
}
catch
{
@@ -1051,7 +1099,7 @@ internal sealed class UpdateEngineService
}
}
private static string? ResolvePdcSourceVersion(PdcFileMap fileMap, PdcUpdateMetadata? metadata)
private static string? ResolvePlondsSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
{
return FirstNonEmpty(
metadata?.FromVersion,
@@ -1060,7 +1108,7 @@ internal sealed class UpdateEngineService
TryGetMetadataValue(fileMap.Metadata, "sourceVersion"));
}
private static string? ResolvePdcTargetVersion(PdcFileMap fileMap, PdcUpdateMetadata? metadata)
private static string? ResolvePlondsTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
{
return FirstNonEmpty(
metadata?.ToVersion,
@@ -1107,7 +1155,7 @@ internal sealed class UpdateEngineService
}
/// <summary>
/// 全新安装场景:直接应用更新包作为首次部署
/// 闁稿繈鍔嶉弻濠勨偓鐟邦槼椤ュ﹪宕烽悜妯荤彲闁挎稒姘ㄥú鍧楀箳閵夈儳瀹夐柣顫妽濞插潡寮弶鍨樁濞达絾绮堢拹鐔革純閺嶎煈鍋ч梺顔哄妿鐠?
/// </summary>
private async Task<LauncherResult> ApplyInitialDeploymentAsync(
SignedFileMap fileMap,
@@ -1123,7 +1171,7 @@ internal sealed class UpdateEngineService
var extractRoot = Path.Combine(_incomingRoot, "extracted");
try
{
// 保存快照(用于回滚,虽然首次安装回滚意义不大)
// Save a snapshot for diagnostics and future rollback consistency.
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
@@ -1136,7 +1184,7 @@ internal sealed class UpdateEngineService
};
SaveSnapshot(snapshotPath, snapshot);
// 清理并解压更新包
// 婵炴挸鎳愰幃濠囩嵁閹澏鎺楀储鐎n偅绾柡鍌涙緲鐎?
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
@@ -1144,17 +1192,17 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(extractRoot);
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
// 创建目标部署目录
// 闁告帗绋戠紓鎾绘儎椤旂晫鍨奸梺顔哄妿鐠佹煡鎯勯鑲╃Э
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
// 应用所有文件(全新安装时,所有文件都是新增或替换)
// Apply all files from the extracted payload into the first deployment directory.
foreach (var file in fileMap.Files)
{
ApplyInitialFileEntry(file, targetDeployment, extractRoot);
}
// 验证文件哈希
// 濡ょ姴鐭侀惁澶愬棘閸ワ附顐介柛婵嗙墕缁?
foreach (var file in fileMap.Files)
{
if (!NeedsVerification(file))
@@ -1170,7 +1218,7 @@ internal sealed class UpdateEngineService
}
}
// 激活部署(创建 .current 标记,删除 .partial 标记)
// Mark the deployment as current and remove the partial marker.
var currentMarker = Path.Combine(targetDeployment, ".current");
File.WriteAllText(currentMarker, string.Empty);
if (File.Exists(partialMarker))
@@ -1178,8 +1226,7 @@ internal sealed class UpdateEngineService
File.Delete(partialMarker);
}
// 清理更新包
snapshot.Status = "applied";
// 婵炴挸鎳愰幃濠囧即鐎涙ɑ鐓€闁? snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
@@ -1195,7 +1242,7 @@ internal sealed class UpdateEngineService
}
catch (Exception ex)
{
// 清理失败的目标目录
// Clean up the failed target deployment before returning the error result.
try
{
if (Directory.Exists(targetDeployment))
@@ -1234,13 +1281,12 @@ internal sealed class UpdateEngineService
}
/// <summary>
/// 应用初始部署文件(全新安装场景,不需要源目录)
/// </summary>
/// 閹煎瓨姊婚弫銈夊礆濠靛棭娼楅梺顔哄妿鐠佹煡寮崶锔筋偨闁挎稑鐗嗛崣蹇涘棘閺夎法鏆旈悷浣告噹濠р偓闁哄拋鍨界槐婵囩▔瀹ュ浠橀悷鏇氱劍缁噣鎯勯鑲╃Э闁? /// </summary>
private void ApplyInitialFileEntry(UpdateFileEntry file, string targetDeployment, string extractRoot)
{
var normalizedPath = NormalizeRelativePath(file.Path);
// 删除操作在全新安装时忽略
// 闁告帞濞€濞呭酣骞欏鍕▕闁革负鍔岄崣蹇涘棘閺夎法鏆旈悷浣告噺濡炲倽绠涢悾灞炬
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
{
return;
@@ -1254,7 +1300,7 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(targetDir);
}
// 无论是 add 还是 replace都从压缩包复制
// 闁哄啰濮鹃鎴﹀及?add 閺夆晜蓱濡?replace闁挎稑鐭傞崗妯荤鎼粹€崇缂傚倵鏅涚€垫ɑ寰勫鍛厬
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
var extractedPath = Path.Combine(extractRoot, archiveRelative);
EnsurePathWithinRoot(extractedPath, extractRoot);
@@ -1419,9 +1465,9 @@ internal sealed class UpdateEngineService
Path.Combine(_incomingRoot, SignedFileMapName),
Path.Combine(_incomingRoot, SignatureFileName),
Path.Combine(_incomingRoot, ArchiveFileName),
Path.Combine(_incomingRoot, PdcFileMapName),
Path.Combine(_incomingRoot, PdcSignatureFileName),
Path.Combine(_incomingRoot, PdcUpdateMetadataName)
Path.Combine(_incomingRoot, PlondsFileMapName),
Path.Combine(_incomingRoot, PlondsSignatureFileName),
Path.Combine(_incomingRoot, PlondsUpdateMetadataName)
})
{
try
@@ -1438,7 +1484,7 @@ internal sealed class UpdateEngineService
foreach (var directory in new[]
{
Path.Combine(_incomingRoot, PdcObjectsDirectoryName)
Path.Combine(_incomingRoot, PlondsObjectsDirectoryName)
})
{
try

View File

@@ -35,16 +35,19 @@ public sealed record UpdateCheckResult(
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage,
bool ForceMode = false,
PdcUpdatePayload? PdcPayload = null);
PlondsUpdatePayload? PlondsPayload = null);
public sealed record PdcUpdatePayload(
public sealed record PlondsUpdatePayload(
string DistributionId,
string ChannelId,
string SubChannel,
string? FileMapJson,
string? FileMapSignature,
string? FileMapJsonUrl,
string? FileMapSignatureUrl);
string? FileMapSignatureUrl,
string? UpdateArchiveUrl = null,
string? UpdateArchiveSha256 = null,
long? UpdateArchiveSizeBytes = null);
public sealed record UpdateDownloadResult(
bool Success,
@@ -159,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var preferredAsset = isUpdateAvailable
? SelectPreferredInstallerAsset(release.Assets)
: null;
var plondsPayload = isUpdateAvailable
? TryResolvePlondsPayload(release)
: null;
return new UpdateCheckResult(
Success: true,
@@ -167,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null);
ErrorMessage: null,
PlondsPayload: plondsPayload);
}
catch (OperationCanceledException)
{
@@ -232,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
: release.TagName;
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
var plondsPayload = TryResolvePlondsPayload(release);
return new UpdateCheckResult(
Success: true,
@@ -241,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null,
ForceMode: true);
ForceMode: true,
PlondsPayload: plondsPayload);
}
catch (OperationCanceledException)
{
@@ -652,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
if (assets is null || assets.Count == 0)
{
return null;
}
@@ -664,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable
_ => "x64"
};
var ranked = assets
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.ToList();
if (OperatingSystem.IsWindows())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
if (OperatingSystem.IsLinux())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreLinuxInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
if (OperatingSystem.IsMacOS())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreMacInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
return null;
}
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
{
if (release.Assets is null || release.Assets.Count == 0)
{
return null;
}
var platformSuffix = GetPlatformAssetSuffix();
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
{
return null;
}
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
var channelId = release.IsPrerelease
? UpdateSettingsValues.ChannelPreview
: UpdateSettingsValues.ChannelStable;
return new PlondsUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: platformSuffix,
FileMapJson: null,
FileMapSignature: null,
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
UpdateArchiveSha256: archiveAsset.Sha256,
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
}
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
{
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
}
private static string GetPlatformAssetSuffix()
{
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X86 => "x86",
Architecture.Arm => "arm",
Architecture.Arm64 => "arm64",
_ => "x64"
};
return $"{os}-{arch}";
}
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
@@ -719,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return score;
}
private static int ScoreLinuxInstallerAsset(string assetName, string architectureToken)
{
if (string.IsNullOrWhiteSpace(assetName))
{
return 0;
}
var score = 0;
if (assetName.EndsWith(".deb", StringComparison.OrdinalIgnoreCase))
{
score += 220;
}
else if (assetName.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase))
{
score += 180;
}
else if (assetName.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
{
score += 160;
}
else
{
return 0;
}
if (assetName.Contains("linux", StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase) ||
(architectureToken == "x64" && assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase)))
{
score += 40;
}
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
return score;
}
private static int ScoreMacInstallerAsset(string assetName, string architectureToken)
{
if (string.IsNullOrWhiteSpace(assetName))
{
return 0;
}
var score = 0;
if (assetName.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase))
{
score += 220;
}
else if (assetName.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
{
score += 180;
}
else
{
return 0;
}
if (assetName.Contains("mac", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("osx", StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
return score;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = null;

View File

@@ -1,580 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
/// <summary>
/// Best-effort PDC client that maps PDC responses to the existing update result model.
/// This keeps launcher update contracts stable while allowing a gradual migration.
/// </summary>
public sealed class PdcReleaseUpdateService : IDisposable
{
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public PdcReleaseUpdateService(HttpClient? httpClient = null)
{
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
var endpoint = ResolveEndpoint();
if (string.IsNullOrWhiteSpace(endpoint))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC endpoint is not configured.",
ForceMode: isForce);
}
try
{
var metadataUrl = BuildUri(endpoint, "api/v1/public/distributions/metadata");
var metadata = await GetContentNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var channelId = ResolveChannelId(metadata, includePrerelease);
if (string.IsNullOrWhiteSpace(channelId))
{
channelId = includePrerelease ? "preview" : "stable";
}
var latestUrl = BuildUri(
endpoint,
$"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
var latestVersionText = ReadString(latestNode, "version") ?? "-";
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution version is invalid.",
ForceMode: isForce);
}
var distributionId = ReadString(latestNode, "distributionId");
if (string.IsNullOrWhiteSpace(distributionId))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution id is missing.",
ForceMode: isForce);
}
var hasUpdate = latestVersion > normalizedCurrentVersion;
if (!isForce && !hasUpdate)
{
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: null,
ForceMode: false);
}
var subChannel = ResolveSubChannel();
var distributionUrl = BuildUri(
endpoint,
$"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}");
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
var assets = ResolveAssets(distributionNode);
var pdcPayload = ResolvePdcPayload(distributionNode, distributionId, channelId, subChannel);
if (assets.Count == 0 && !HasPdcPayload(pdcPayload))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC distribution response does not expose downloadable update assets.",
ForceMode: isForce);
}
var release = new GitHubReleaseInfo(
TagName: $"v{latestVersionText}",
Name: $"PDC Distribution {latestVersionText}",
IsPrerelease: includePrerelease,
IsDraft: false,
PublishedAt: DateTimeOffset.UtcNow,
Assets: assets);
var preferredAsset = SelectPreferredInstallerAsset(assets);
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: true,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null,
ForceMode: isForce,
PdcPayload: pdcPayload);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: $"PDC request failed: {ex.Message}",
ForceMode: isForce);
}
}
private async Task<JsonElement> GetContentNodeAsync(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var token = ResolveToken();
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}");
}
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("content", out var content))
{
return content.Clone();
}
return root.Clone();
}
private static IReadOnlyList<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
{
var assets = new List<GitHubReleaseAsset>();
if (distributionNode.ValueKind != JsonValueKind.Object)
{
return assets;
}
if (distributionNode.TryGetProperty("assets", out var assetsNode) &&
assetsNode.ValueKind == JsonValueKind.Array)
{
foreach (var assetNode in assetsNode.EnumerateArray())
{
if (assetNode.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = ReadString(assetNode, "name");
var url = ReadString(assetNode, "url") ??
ReadString(assetNode, "downloadUrl") ??
ReadString(assetNode, "browserDownloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
}
var size = ReadInt64(assetNode, "size") ?? 0L;
var sha256 = ReadString(assetNode, "sha256");
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
}
}
if (assets.Count > 0)
{
return assets;
}
// Field-level fallback for service-side URL projection.
var manifestUrl = ReadString(distributionNode, "manifestUrl")
?? ReadString(distributionNode, "fileMapUrl");
var signatureUrl = ReadString(distributionNode, "signatureUrl")
?? ReadString(distributionNode, "fileMapSignatureUrl");
var archiveUrl = ReadString(distributionNode, "archiveUrl")
?? ReadString(distributionNode, "updateArchiveUrl")
?? ReadString(distributionNode, "payloadUrl");
if (!string.IsNullOrWhiteSpace(manifestUrl))
{
assets.Add(new GitHubReleaseAsset("files.json", manifestUrl, 0, null));
}
if (!string.IsNullOrWhiteSpace(signatureUrl))
{
assets.Add(new GitHubReleaseAsset("files.json.sig", signatureUrl, 0, null));
}
if (!string.IsNullOrWhiteSpace(archiveUrl))
{
assets.Add(new GitHubReleaseAsset("update.zip", archiveUrl, 0, null));
}
return assets;
}
private static PdcUpdatePayload ResolvePdcPayload(
JsonElement distributionNode,
string distributionId,
string channelId,
string subChannel)
{
var fileMapJson = ReadString(distributionNode, "fileMapJson");
var fileMapSignature = ReadString(distributionNode, "fileMapSignature");
var fileMapJsonUrl = ReadString(distributionNode, "fileMapJsonUrl")
?? ReadString(distributionNode, "fileMapUrl")
?? ReadString(distributionNode, "manifestUrl");
var fileMapSignatureUrl = ReadString(distributionNode, "fileMapSignatureUrl")
?? ReadString(distributionNode, "signatureUrl");
return new PdcUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: subChannel,
FileMapJson: fileMapJson,
FileMapSignature: fileMapSignature,
FileMapJsonUrl: fileMapJsonUrl,
FileMapSignatureUrl: fileMapSignatureUrl);
}
private static bool HasPdcPayload(PdcUpdatePayload payload)
{
return !string.IsNullOrWhiteSpace(payload.FileMapJson)
|| !string.IsNullOrWhiteSpace(payload.FileMapJsonUrl);
}
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0)
{
return null;
}
if (OperatingSystem.IsWindows())
{
var archToken = RuntimeInformation.OSArchitecture switch
{
Architecture.Arm64 => "arm64",
Architecture.X86 => "x86",
_ => "x64"
};
return assets
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".exe", ".msi", archToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
if (OperatingSystem.IsLinux())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".deb", ".rpm", "x64")))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
if (OperatingSystem.IsMacOS())
{
var archToken = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64";
return assets
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".dmg", ".pkg", archToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
return null;
}
private static int ScoreInstallerAsset(string name, string ext1, string ext2, string archToken)
{
if (string.IsNullOrWhiteSpace(name))
{
return 0;
}
var score = 0;
if (name.EndsWith(ext1, StringComparison.OrdinalIgnoreCase))
{
score += 200;
}
else if (name.EndsWith(ext2, StringComparison.OrdinalIgnoreCase))
{
score += 160;
}
else
{
return 0;
}
if (name.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
name.Contains("installer", StringComparison.OrdinalIgnoreCase))
{
score += 50;
}
if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (name.Contains("portable", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
return score;
}
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
{
if (metadataNode.ValueKind != JsonValueKind.Object ||
!metadataNode.TryGetProperty("channels", out var channelsNode))
{
return includePrerelease ? "preview" : "stable";
}
var defaultChannelId = ReadString(metadataNode, "defaultChannelId") ?? string.Empty;
if (channelsNode.ValueKind != JsonValueKind.Object)
{
return defaultChannelId;
}
string? matchedPreview = null;
string? matchedStable = null;
foreach (var channel in channelsNode.EnumerateObject())
{
var name = ReadString(channel.Value, "name") ?? channel.Name;
if (string.IsNullOrWhiteSpace(matchedPreview) &&
(name.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
name.Contains("beta", StringComparison.OrdinalIgnoreCase) ||
name.Contains("dev", StringComparison.OrdinalIgnoreCase)))
{
matchedPreview = channel.Name;
}
if (string.IsNullOrWhiteSpace(matchedStable) &&
(name.Contains("stable", StringComparison.OrdinalIgnoreCase) ||
name.Contains("release", StringComparison.OrdinalIgnoreCase)))
{
matchedStable = channel.Name;
}
}
if (includePrerelease)
{
return matchedPreview ?? defaultChannelId ?? "preview";
}
return matchedStable ?? defaultChannelId ?? "stable";
}
private static string ResolveSubChannel()
{
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_SUBCHANNEL")
?? Environment.GetEnvironmentVariable("PDC_SUBCHANNEL");
if (!string.IsNullOrWhiteSpace(configured))
{
return configured.Trim();
}
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X86 => "x86",
Architecture.Arm => "arm",
Architecture.Arm64 => "arm64",
_ => "x64"
};
return $"{os}_{arch}_release_folderClassic";
}
private static string? ResolveEndpoint()
{
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT")
?? Environment.GetEnvironmentVariable("PDC_ENDPOINT");
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
}
private static string? ResolveToken()
{
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN")
?? Environment.GetEnvironmentVariable("PDC_TOKEN");
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
}
private static string BuildUri(string endpoint, string relativePath)
{
return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}";
}
private static string? ReadString(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String
? value.GetString()
: value.ToString();
}
private static long? ReadInt64(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
{
return null;
}
if (value.TryGetInt64(out var number))
{
return number;
}
var text = value.ToString();
return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim().TrimStart('v', 'V');
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = NormalizeVersion(parsed);
return true;
}
private static Version NormalizeVersion(Version version)
{
var major = Math.Max(0, version.Major);
var minor = Math.Max(0, version.Minor);
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
return revision > 0
? new Version(major, minor, build, revision)
: new Version(major, minor, build);
}
private static string FormatVersionText(Version version)
{
return version.Revision > 0
? version.ToString(4)
: version.ToString(3);
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength];
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
/// <summary>
/// Release-backed PLONDS checker.
/// It only succeeds when the latest GitHub Release already exposes platform PLONDS assets.
/// If those assets are not ready yet, callers can fall back to the normal GitHub installer flow.
/// </summary>
public sealed class PlondsReleaseUpdateService : IDisposable
{
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
public Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var releaseResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (!releaseResult.Success)
{
return releaseResult;
}
if (!isForce && !releaseResult.IsUpdateAvailable)
{
return releaseResult with { ForceMode = false };
}
if (releaseResult.PlondsPayload is not null)
{
return releaseResult with { ForceMode = isForce };
}
var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText)
? "-"
: releaseResult.LatestVersionText;
var message = releaseResult.Release is null
? "GitHub Release data is unavailable for PLONDS."
: $"Release {latestVersion} does not expose platform PLONDS assets yet.";
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: releaseResult.IsUpdateAvailable,
CurrentVersionText: releaseResult.CurrentVersionText,
LatestVersionText: latestVersion,
Release: releaseResult.Release,
PreferredAsset: releaseResult.PreferredAsset,
ErrorMessage: message,
ForceMode: isForce,
PlondsPayload: null);
}
}

View File

@@ -356,7 +356,7 @@ public interface IUpdateSettingsService
void Save(UpdateSettingsState state);
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,

View File

@@ -752,7 +752,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
{
private readonly ISettingsService _settingsService;
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new();
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
public UpdateSettingsService(ISettingsService settingsService)
{
@@ -842,16 +842,16 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public async Task<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
Version currentVersion,
bool includePrerelease,
bool isForce = false,
CancellationToken cancellationToken = default)
{
var result = isForce
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return result.Success ? result.PdcPayload : null;
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return result.Success ? result.PlondsPayload : null;
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
@@ -891,7 +891,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
_pdcReleaseUpdateService.Dispose();
_plondsReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
@@ -901,20 +901,39 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
CancellationToken cancellationToken)
{
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
var pdcResult = isForce
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
var plondsResult = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (pdcResult.Success)
if (plondsResult.Success)
{
return pdcResult;
return plondsResult;
}
AppLogger.Warn(
"UpdateSettings",
$"PDC update check failed and will fallback to GitHub. Error: {pdcResult.ErrorMessage}");
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
{
AppLogger.Info(
"UpdateSettings",
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
}
else
{
AppLogger.Warn(
"UpdateSettings",
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
}
return githubFallbackResult;
}
return isForce
@@ -1271,14 +1290,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppVersionText()
{
// 优先从环境变量读取(Launcher 传递)
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
if (!string.IsNullOrWhiteSpace(envVersion))
{
return envVersion;
}
// 回退:从程序集读取
// Fallback: read from application assembly.
var assembly = typeof(App).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
@@ -1318,14 +1337,14 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppCodenameText()
{
// 优先从环境变量读取(Launcher 传递)
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
if (!string.IsNullOrWhiteSpace(envCodename))
{
return envCodename;
}
// 回退:使用默认开发代号
// Fallback: use default codename.
return DefaultCodename;
}

View File

@@ -12,9 +12,11 @@ public static class UpdateSettingsValues
public const string ModeSilentOnExit = "silent_on_exit";
// NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePdc = "stcn";
public const string DownloadSourceStcn = DownloadSourcePdc;
public const string LegacyDownloadSourcePdc = "pdc";
public const string DownloadSourcePlonds = "stcn";
public const string DownloadSourcePdc = DownloadSourcePlonds;
public const string DownloadSourceStcn = DownloadSourcePlonds;
public const string LegacyDownloadSourcePlonds = "pdc";
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
@@ -55,14 +57,14 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
if (string.Equals(value, LegacyDownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceStcn;
}
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePdc;
return DownloadSourcePlonds;
}
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
@@ -75,7 +77,7 @@ public static class UpdateSettingsValues
return DownloadSourceGitHub;
}
// Default to STCN(PDC/S3). Runtime will fallback to GitHub if STCN is unavailable.
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
return DownloadSourceStcn;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1965,7 +1965,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
return;
}
if (result.PreferredAsset is null)
if (result.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{
UpdateStatus = isForce
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
@@ -2050,7 +2050,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
private async Task RedownloadUpdateAsync()
{
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
if (_lastCheckResult is null ||
!_lastCheckResult.Success ||
!_lastCheckResult.IsUpdateAvailable ||
(_lastCheckResult.PreferredAsset is null && !UpdateWorkflowService.IsDeltaUpdateAvailable(_lastCheckResult)))
{
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
return;
@@ -2233,11 +2236,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateDownloadResult downloadResult;
// Prefer delta update if available (smaller download, faster)
if (result.Release is not null && UpdateWorkflowService.IsDeltaUpdateAvailable(result.Release))
if (UpdateWorkflowService.IsDeltaUpdateAvailable(result))
{
UpdateStatus = L("settings.update.status_downloading_delta", "Downloading incremental update...");
downloadResult = await _updateWorkflowService.DownloadDeltaUpdateAsync(result, progress);
if (!downloadResult.Success)
if (!downloadResult.Success && result.PlondsPayload is null)
{
// Delta download failed, fall back to full installer
AppLogger.Warn("UpdateSettings", $"Delta update download failed: {downloadResult.ErrorMessage}. Falling back to full installer.");

View File

@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<VersionPrefix>0.1.0</VersionPrefix>
<PackageVersion>0.1.0</PackageVersion>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
<FileVersion>0.1.0.0</FileVersion>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,93 @@
# PLONDS 骨架
Penguin Logistics Online Network Distribution System企鹅物流在线网络分发系统简称 PLONDS是 LanMountainDesktop 的独立更新分发骨架。
本目录有意与主应用和启动器隔离,仅包含新的分发协议、一个轻量级的只读 API以及示例 S3 风格的元数据文件。
## 目录结构
```text
PenguinLogisticsOnlineNetworkDistributionSystem/
README.md
src/
Plonds.Shared/
Plonds.Api/
sample-data/
meta/
channels/
stable/
windows-x64/
windows-x86/
linux-x64/
distributions/
```
## 项目说明
- `Plonds.Shared` 提供协议常量和数据模型。
- `Plonds.Core` 负责哈希计算、差异生成、对象仓库生成、清单生成、签名和发布编排。
- `Plonds.Tool` 是面向 CI 的命令行入口。PowerShell 脚本应保持为围绕此工具的薄包装层。
- `Plonds.Api` 是一个轻量级只读 API从类似 S3 布局的本地文件夹中读取元数据。
## 架构设计
PLONDS 有意围绕单一的 C# 实现栈构建,以确保协议和发布行为不会在不同语言之间产生偏差。
```text
宿主应用
-> 检查更新、下载对象、暂存传入的负载
启动器
-> 验证签名、应用文件映射、切换部署、回滚
PLONDS.Api
-> 面向客户端的只读元数据投影
PLONDS.Tool
-> CI/发布命令界面
PLONDS.Core
-> 哈希/差异/对象仓库/签名/发布实现
PLONDS.Shared
-> 协议常量和 DTO
```
## v1 规则
- 核心协议行为应位于 `Plonds.Core` 中,而非 PowerShell 脚本。
- `scripts/*.ps1` 仅可作为 GitHub Actions 和本地便利的薄包装层保留。
- 宿主应用保留下载职责。
- 启动器保留应用、原子切换、快照和回滚职责。
## 存储布局
第一版本保持固定的对象根目录:
```text
lanmountain/update/
repo/sha256/<前缀>/<哈希>
meta/channels/<频道>/<平台>/latest.json
meta/distributions/<分发ID>.json
installers/<平台>/<版本>/...
```
已规划但 v1 中未启用:
```text
lanmountain/update/repo-compressed/<算法>/<前缀>/<哈希>
lanmountain/update/patches/<算法>/<基础哈希>/<目标哈希>
```
## 公共接口
API 基础路径为 `/api/plonds/v1`
- `GET /healthz` - 健康检查
- `GET /api/plonds/v1/metadata` - 获取元数据目录
- `GET /api/plonds/v1/channels/{channel}/{platform}/latest?currentVersion=...` - 获取指定频道和平台的最新版本
- `GET /api/plonds/v1/distributions/{distributionId}` - 获取指定分发版本的完整信息
## 本地运行
```powershell
dotnet run --project src/Plonds.Api
```
默认情况下API 从 `sample-data` 读取元数据。

View File

@@ -0,0 +1,10 @@
{
"channel": "stable",
"platform": "linux-x64",
"distributionId": "plonds-0.8.5.2-linux-x64",
"version": "0.8.5.2",
"publishedAt": "2026-04-20T00:00:00Z",
"distributionPath": "meta/distributions/plonds-0.8.5.2-linux-x64.json",
"fileMapPath": "meta/distributions/plonds-0.8.5.2-linux-x64.json"
}

View File

@@ -0,0 +1,10 @@
{
"channel": "stable",
"platform": "windows-x64",
"distributionId": "plonds-0.8.5.2-windows-x64",
"version": "0.8.5.2",
"publishedAt": "2026-04-20T00:00:00Z",
"distributionPath": "meta/distributions/plonds-0.8.5.2-windows-x64.json",
"fileMapPath": "meta/distributions/plonds-0.8.5.2-windows-x64.json"
}

View File

@@ -0,0 +1,10 @@
{
"channel": "stable",
"platform": "windows-x86",
"distributionId": "plonds-0.8.5.2-windows-x86",
"version": "0.8.5.2",
"publishedAt": "2026-04-20T00:00:00Z",
"distributionPath": "meta/distributions/plonds-0.8.5.2-windows-x86.json",
"fileMapPath": "meta/distributions/plonds-0.8.5.2-windows-x86.json"
}

View File

@@ -0,0 +1,66 @@
{
"distributionId": "plonds-0.8.5.2-linux-x64",
"version": "0.8.5.2",
"channel": "stable",
"platform": "linux-x64",
"publishedAt": "2026-04-20T00:00:00Z",
"components": [
{
"id": "app",
"root": "app-0.8.5.2/",
"mode": "file-object",
"metadata": {
"allowDiffUpdate": "true"
},
"files": [
{
"path": "LanMountainDesktop",
"op": "replace",
"contentHash": "sha256-placeholder-lanmountain-linux",
"size": 2048000,
"mode": "file-object",
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-linux"
}
]
},
{
"id": "installers",
"root": "installers/linux-x64/",
"mode": "file-object",
"files": [
{
"path": "LanMountainDesktop-0.8.5.2-linux-x64.deb",
"op": "add",
"contentHash": "sha256-placeholder-linux-x64-installer",
"size": 3096576,
"mode": "file-object",
"objectKey": "installers/linux-x64/LanMountainDesktop-0.8.5.2-linux-x64.deb"
}
]
}
],
"installerMirrors": [
{
"platform": "linux",
"arch": "x64",
"url": "https://downloads.example.invalid/lanmountain/linux-x64/LanMountainDesktop-0.8.5.2-linux-x64.deb",
"fileName": "LanMountainDesktop-0.8.5.2-linux-x64.deb"
}
],
"capabilities": [
"file-object",
"compressed-object",
"binary-patch"
],
"signatures": [
{
"algorithm": "rsa-sha256",
"keyId": "lanmountain-main",
"signature": "placeholder-signature"
}
],
"metadata": {
"notes": "sample distribution for PLONDS skeleton"
}
}

View File

@@ -0,0 +1,66 @@
{
"distributionId": "plonds-0.8.5.2-windows-x64",
"version": "0.8.5.2",
"channel": "stable",
"platform": "windows-x64",
"publishedAt": "2026-04-20T00:00:00Z",
"components": [
{
"id": "app",
"root": "app-0.8.5.2/",
"mode": "file-object",
"metadata": {
"allowDiffUpdate": "true"
},
"files": [
{
"path": "LanMountainDesktop.exe",
"op": "replace",
"contentHash": "sha256-placeholder-lanmountain-exe",
"size": 1024000,
"mode": "file-object",
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-exe"
}
]
},
{
"id": "installers",
"root": "installers/windows-x64/",
"mode": "file-object",
"files": [
{
"path": "LanMountainDesktop-Setup-0.8.5.2-x64.exe",
"op": "add",
"contentHash": "sha256-placeholder-windows-x64-installer",
"size": 2048000,
"mode": "file-object",
"objectKey": "installers/windows-x64/LanMountainDesktop-Setup-0.8.5.2-x64.exe"
}
]
}
],
"installerMirrors": [
{
"platform": "windows",
"arch": "x64",
"url": "https://downloads.example.invalid/lanmountain/windows-x64/LanMountainDesktop-Setup-0.8.5.2-x64.exe",
"fileName": "LanMountainDesktop-Setup-0.8.5.2-x64.exe"
}
],
"capabilities": [
"file-object",
"compressed-object",
"binary-patch"
],
"signatures": [
{
"algorithm": "rsa-sha256",
"keyId": "lanmountain-main",
"signature": "placeholder-signature"
}
],
"metadata": {
"notes": "sample distribution for PLONDS skeleton"
}
}

View File

@@ -0,0 +1,66 @@
{
"distributionId": "plonds-0.8.5.2-windows-x86",
"version": "0.8.5.2",
"channel": "stable",
"platform": "windows-x86",
"publishedAt": "2026-04-20T00:00:00Z",
"components": [
{
"id": "app",
"root": "app-0.8.5.2/",
"mode": "file-object",
"metadata": {
"allowDiffUpdate": "true"
},
"files": [
{
"path": "LanMountainDesktop.exe",
"op": "replace",
"contentHash": "sha256-placeholder-lanmountain-exe-x86",
"size": 983040,
"mode": "file-object",
"objectKey": "repo/sha256/sha256-placeholder-lanmountain-exe-x86"
}
]
},
{
"id": "installers",
"root": "installers/windows-x86/",
"mode": "file-object",
"files": [
{
"path": "LanMountainDesktop-Setup-0.8.5.2-x86.exe",
"op": "add",
"contentHash": "sha256-placeholder-windows-x86-installer",
"size": 1982464,
"mode": "file-object",
"objectKey": "installers/windows-x86/LanMountainDesktop-Setup-0.8.5.2-x86.exe"
}
]
}
],
"installerMirrors": [
{
"platform": "windows",
"arch": "x86",
"url": "https://downloads.example.invalid/lanmountain/windows-x86/LanMountainDesktop-Setup-0.8.5.2-x86.exe",
"fileName": "LanMountainDesktop-Setup-0.8.5.2-x86.exe"
}
],
"capabilities": [
"file-object",
"compressed-object",
"binary-patch"
],
"signatures": [
{
"algorithm": "rsa-sha256",
"keyId": "lanmountain-main",
"signature": "placeholder-signature"
}
],
"metadata": {
"notes": "sample distribution for PLONDS skeleton"
}
}

View File

@@ -0,0 +1,11 @@
namespace Plonds.Api.Configuration;
public sealed class PlondsApiOptions
{
public string StorageRoot { get; set; } = Plonds.Shared.PlondsConstants.DefaultStorageRoot;
public string MetaRoot { get; set; } = Plonds.Shared.PlondsConstants.DefaultMetaRoot;
public string ApiBasePath { get; set; } = Plonds.Shared.PlondsConstants.DefaultApiBasePath;
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>Plonds.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,86 @@
using Microsoft.Extensions.Options;
using Plonds.Api.Configuration;
using Plonds.Api.Services;
using Plonds.Shared;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<PlondsApiOptions>(builder.Configuration.GetSection("Plonds"));
builder.Services.AddSingleton(sp =>
{
var options = sp.GetRequiredService<IOptions<PlondsApiOptions>>().Value;
return options;
});
builder.Services.AddSingleton<IPlondsManifestStore>(sp =>
{
var options = sp.GetRequiredService<PlondsApiOptions>();
return new FileSystemPlondsManifestStore(options);
});
var app = builder.Build();
var apiBasePath = app.Configuration["Plonds:ApiBasePath"];
if (string.IsNullOrWhiteSpace(apiBasePath))
{
apiBasePath = PlondsConstants.DefaultApiBasePath;
}
if (!apiBasePath.StartsWith('/'))
{
apiBasePath = "/" + apiBasePath;
}
app.MapGet("/healthz", () => Results.Ok(new { status = "ok", protocol = PlondsConstants.ProtocolName, version = PlondsConstants.ProtocolVersion }));
app.MapGet($"{apiBasePath}/metadata", async (IPlondsManifestStore store, CancellationToken cancellationToken) =>
{
var catalog = await store.GetCatalogAsync(cancellationToken);
return Results.Ok(catalog);
});
app.MapGet($"{apiBasePath}/channels/{{channel}}/{{platform}}/latest", async (
string channel,
string platform,
string? currentVersion,
IPlondsManifestStore store,
CancellationToken cancellationToken) =>
{
var latest = await store.GetLatestAsync(channel, platform, cancellationToken);
if (latest is null)
{
return Results.NotFound(new
{
error = "latest_pointer_not_found",
channel,
platform
});
}
if (!string.IsNullOrWhiteSpace(currentVersion) &&
Version.TryParse(currentVersion, out var current) &&
Version.TryParse(latest.Version, out var target) &&
target <= current)
{
return Results.NoContent();
}
return Results.Ok(latest);
});
app.MapGet($"{apiBasePath}/distributions/{{distributionId}}", async (string distributionId, IPlondsManifestStore store, CancellationToken cancellationToken) =>
{
var distribution = await store.GetDistributionAsync(distributionId, cancellationToken);
if (distribution is null)
{
return Results.NotFound(new
{
error = "distribution_not_found",
distributionId
});
}
return Results.Ok(distribution);
});
app.Run();

View File

@@ -0,0 +1,138 @@
using System.Text.Json;
using Plonds.Api.Configuration;
using Plonds.Shared;
using Plonds.Shared.Models;
namespace Plonds.Api.Services;
public sealed class FileSystemPlondsManifestStore : IPlondsManifestStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly PlondsApiOptions _options;
private readonly string _storageRootFullPath;
private readonly string _metaRootFullPath;
public FileSystemPlondsManifestStore(PlondsApiOptions options)
{
_options = options;
_storageRootFullPath = ResolveRootPath(options.StorageRoot);
_metaRootFullPath = Path.Combine(_storageRootFullPath, options.MetaRoot);
}
public Task<PlondsMetadataCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
_ = cancellationToken;
var channelsRoot = Path.Combine(_metaRootFullPath, "channels");
var latest = new List<PlondsChannelPointer>();
if (Directory.Exists(channelsRoot))
{
foreach (var latestPath in Directory.EnumerateFiles(channelsRoot, "latest.json", SearchOption.AllDirectories))
{
var pointer = ReadLatestPointer(latestPath);
if (pointer is not null)
{
latest.Add(pointer);
}
}
}
var catalog = new PlondsMetadataCatalog(
ProtocolName: PlondsConstants.ProtocolName,
ProtocolVersion: PlondsConstants.ProtocolVersion,
StorageRoot: _storageRootFullPath,
MetaRoot: _metaRootFullPath,
Latest: latest.OrderBy(x => x.Channel, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Platform, StringComparer.OrdinalIgnoreCase)
.ToArray(),
Metadata: new Dictionary<string, string>
{
["apiBasePath"] = PlondsConstants.DefaultApiBasePath
});
return Task.FromResult(catalog);
}
public Task<PlondsChannelPointer?> GetLatestAsync(string channel, string platform, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return Task.FromResult(ReadLatestPointer(GetLatestPath(channel, platform)));
}
public Task<PlondsDistributionInfo?> GetDistributionAsync(string distributionId, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
var path = GetDistributionPath(distributionId);
if (!File.Exists(path))
{
return Task.FromResult<PlondsDistributionInfo?>(null);
}
var json = File.ReadAllText(path);
var distribution = JsonSerializer.Deserialize<PlondsDistributionInfo>(json, JsonOptions);
return Task.FromResult(distribution);
}
private PlondsChannelPointer? ReadLatestPointer(string path)
{
if (!File.Exists(path))
{
return null;
}
var json = File.ReadAllText(path);
var pointer = JsonSerializer.Deserialize<PlondsChannelPointer>(json, JsonOptions);
return pointer;
}
private string GetLatestPath(string channel, string platform)
{
return Path.Combine(_metaRootFullPath, "channels", channel, platform, "latest.json");
}
private string GetDistributionPath(string distributionId)
{
return Path.Combine(_metaRootFullPath, "distributions", $"{distributionId}.json");
}
private static string ResolveRootPath(string root)
{
if (Path.IsPathRooted(root))
{
return Path.GetFullPath(root);
}
var candidates = new List<string>();
AddCandidateChain(candidates, Directory.GetCurrentDirectory(), root);
AddCandidateChain(candidates, AppContext.BaseDirectory, root);
foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
{
if (Directory.Exists(candidate))
{
return candidate;
}
}
return candidates.FirstOrDefault() ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, root));
}
private static void AddCandidateChain(ICollection<string> candidates, string? startDirectory, string relativeRoot)
{
var current = string.IsNullOrWhiteSpace(startDirectory)
? null
: Path.GetFullPath(startDirectory);
while (!string.IsNullOrWhiteSpace(current))
{
candidates.Add(Path.GetFullPath(Path.Combine(current, relativeRoot)));
current = Directory.GetParent(current)?.FullName;
}
}
}

View File

@@ -0,0 +1,13 @@
using Plonds.Shared.Models;
namespace Plonds.Api.Services;
public interface IPlondsManifestStore
{
Task<PlondsMetadataCatalog> GetCatalogAsync(CancellationToken cancellationToken = default);
Task<PlondsChannelPointer?> GetLatestAsync(string channel, string platform, CancellationToken cancellationToken = default);
Task<PlondsDistributionInfo?> GetDistributionAsync(string distributionId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
{
"Plonds": {
"StorageRoot": "sample-data",
"MetaRoot": "meta",
"ApiBasePath": "/api/plonds/v1"
}
}

View File

@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,68 @@
using Plonds.Core.Security;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class DdssManifestBuilder
{
private readonly RsaFileSigner _signer = new();
public string Build(DdssBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var assetsDirectory = Path.GetFullPath(options.AssetsDirectory);
if (!Directory.Exists(assetsDirectory))
{
throw new DirectoryNotFoundException($"DDSS assets directory not found: {assetsDirectory}");
}
var assetEntries = Directory
.EnumerateFiles(assetsDirectory, "*", SearchOption.TopDirectoryOnly)
.Where(static path =>
{
var name = Path.GetFileName(path);
return !name.Equals("ddss.json", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("ddss.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 DdssManifest(
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, "ddss.json");
PayloadUtilities.WriteJson(manifestPath, manifest);
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
return manifestPath;
}
private static DdssAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl)
{
var fileName = Path.GetFileName(assetPath);
var mirrors = new List<DdssMirrorEntry>
{
new("github", $"https://github.com/{repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(fileName)}")
};
if (!string.IsNullOrWhiteSpace(s3BaseUrl))
{
mirrors.Add(new DdssMirrorEntry(
"s3",
$"{s3BaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}"));
}
return new DdssAssetEntry(
AssetId: fileName,
FileName: fileName,
Sha256: PayloadUtilities.ComputeSha256(assetPath),
Size: new FileInfo(assetPath).Length,
Mirrors: mirrors);
}
}

View File

@@ -0,0 +1,235 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Plonds.Core.Publishing;
public static class PayloadUtilities
{
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public static void CreatePayloadZip(string sourceDirectory, string outputZipPath)
{
var resolvedSourceDirectory = Path.GetFullPath(sourceDirectory);
if (!Directory.Exists(resolvedSourceDirectory))
{
throw new DirectoryNotFoundException($"Payload source directory not found: {resolvedSourceDirectory}");
}
var resolvedOutputZipPath = Path.GetFullPath(outputZipPath);
var outputDirectory = Path.GetDirectoryName(resolvedOutputZipPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
if (File.Exists(resolvedOutputZipPath))
{
File.Delete(resolvedOutputZipPath);
}
using var archive = ZipFile.Open(resolvedOutputZipPath, ZipArchiveMode.Create);
foreach (var filePath in Directory.EnumerateFiles(resolvedSourceDirectory, "*", SearchOption.AllDirectories))
{
var relativePath = NormalizeRelativePath(Path.GetRelativePath(resolvedSourceDirectory, filePath));
if (ShouldIgnore(relativePath))
{
continue;
}
archive.CreateEntryFromFile(filePath, relativePath, CompressionLevel.Optimal);
}
}
internal static void ExtractZip(string zipPath, string destinationDirectory)
{
var resolvedZipPath = Path.GetFullPath(zipPath);
if (!File.Exists(resolvedZipPath))
{
throw new FileNotFoundException("Payload archive not found.", resolvedZipPath);
}
EnsureCleanDirectory(destinationDirectory);
ZipFile.ExtractToDirectory(resolvedZipPath, destinationDirectory, overwriteFiles: true);
}
internal 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 = NormalizeRelativePath(Path.GetRelativePath(resolvedRoot, filePath));
if (ShouldIgnore(relativePath))
{
continue;
}
var fileInfo = new FileInfo(filePath);
manifest[relativePath] = new FileFingerprint(
relativePath,
filePath,
ComputeSha256(filePath),
fileInfo.Length,
ResolveUnixFileMode(filePath));
}
return manifest;
}
internal static string CopyObject(string sourcePath, string objectsRoot, string sha256)
{
var normalizedSha256 = sha256.Trim().ToLowerInvariant();
var prefix = normalizedSha256[..Math.Min(2, normalizedSha256.Length)];
var relativePath = NormalizeRelativePath(Path.Combine(prefix, normalizedSha256));
var destinationPath = Path.Combine(objectsRoot, prefix, normalizedSha256);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (!File.Exists(destinationPath))
{
File.Copy(sourcePath, destinationPath, overwrite: true);
}
return relativePath;
}
internal static void EnsureCleanDirectory(string path)
{
var resolvedPath = Path.GetFullPath(path);
if (Directory.Exists(resolvedPath))
{
Directory.Delete(resolvedPath, recursive: true);
}
Directory.CreateDirectory(resolvedPath);
}
internal static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
internal static void WriteJson<T>(string path, T value)
{
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(value, JsonOptions);
File.WriteAllText(path, json, new UTF8Encoding(false));
}
internal static string NormalizeRelativePath(string value)
{
return value.Replace('\\', '/').TrimStart('/');
}
internal static string ResolveArch(string platform)
{
if (platform.EndsWith("-x86", StringComparison.OrdinalIgnoreCase))
{
return "x86";
}
if (platform.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase))
{
return "arm64";
}
return "x64";
}
internal static bool ShouldIgnore(string relativePath)
{
var normalized = NormalizeRelativePath(relativePath.Trim());
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)
|| normalized.StartsWith("logs/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("cache/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("snapshots/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("snapshot/", StringComparison.OrdinalIgnoreCase);
}
private static string? ResolveUnixFileMode(string path)
{
if (OperatingSystem.IsWindows())
{
return null;
}
try
{
var mode = File.GetUnixFileMode(path);
return Convert.ToString((int)mode, 8);
}
catch
{
return InferUnixFileMode(path);
}
}
private static string? InferUnixFileMode(string path)
{
if (!LooksExecutable(path))
{
return null;
}
return "755";
}
private static bool LooksExecutable(string path)
{
try
{
using var stream = File.OpenRead(path);
Span<byte> header = stackalloc byte[4];
var read = stream.Read(header);
if (read >= 4 &&
header[0] == 0x7F &&
header[1] == (byte)'E' &&
header[2] == (byte)'L' &&
header[3] == (byte)'F')
{
return true;
}
if (read >= 2 && header[0] == (byte)'#' && header[1] == (byte)'!')
{
return true;
}
}
catch
{
return false;
}
var extension = Path.GetExtension(path);
return string.IsNullOrWhiteSpace(extension) &&
!OperatingSystem.IsWindows() &&
Path.GetFileName(path).Contains("LanMountainDesktop", StringComparison.OrdinalIgnoreCase);
}
internal sealed record FileFingerprint(string RelativePath, string FullPath, string Sha256, long Size, string? UnixFileMode);
}

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,14 @@
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);

View File

@@ -0,0 +1,13 @@
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);

View File

@@ -0,0 +1,228 @@
using Plonds.Core.Security;
using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class PlondsDeltaBuilder
{
private readonly RsaFileSigner _signer = new();
public PlondsDeltaBuildResult Build(PlondsDeltaBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var currentPayloadZip = Path.GetFullPath(options.CurrentPayloadZip);
if (!File.Exists(currentPayloadZip))
{
throw new FileNotFoundException("Current payload zip not found.", currentPayloadZip);
}
var baselinePayloadZip = string.IsNullOrWhiteSpace(options.BaselinePayloadZip)
? null
: Path.GetFullPath(options.BaselinePayloadZip);
if (!string.IsNullOrWhiteSpace(baselinePayloadZip) && !File.Exists(baselinePayloadZip))
{
throw new FileNotFoundException("Baseline payload zip not found.", baselinePayloadZip);
}
var outputRoot = Path.GetFullPath(options.OutputRoot);
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);
PayloadUtilities.ExtractZip(currentPayloadZip, currentExtractRoot);
var useFullPayload = options.IsFullPayload || string.IsNullOrWhiteSpace(baselinePayloadZip);
if (useFullPayload)
{
PayloadUtilities.EnsureCleanDirectory(baselineExtractRoot);
}
else
{
PayloadUtilities.ExtractZip(baselinePayloadZip!, baselineExtractRoot);
}
PayloadUtilities.EnsureCleanDirectory(objectsRoot);
var previousManifest = useFullPayload
? new Dictionary<string, PayloadUtilities.FileFingerprint>(StringComparer.OrdinalIgnoreCase)
: PayloadUtilities.ScanDirectory(baselineExtractRoot);
var currentManifest = PayloadUtilities.ScanDirectory(currentExtractRoot);
var fileEntries = BuildFileEntries(previousManifest, currentManifest, objectsRoot);
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);
PayloadUtilities.CreatePayloadZip(objectsRoot, updateArchivePath);
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 component = new ComponentDocument(
Name: "app",
Version: options.CurrentVersion,
Metadata: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["component"] = "app",
["mode"] = "file-object"
},
Files: fileEntries);
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: DateTimeOffset.UtcNow,
Metadata: metadata,
Components: [component],
Files: fileEntries);
PayloadUtilities.WriteJson(fileMapPath, fileMap);
_signer.SignFile(fileMapPath, options.PrivateKeyPath, fileMapSignaturePath);
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)
{
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,
Metadata: null));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var objectPath = PayloadUtilities.CopyObject(current.FullPath, objectsRoot, current.Sha256);
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,
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,
Metadata: null));
}
return result;
}
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 sealed record ComponentDocument(
string Name,
string Version,
IReadOnlyDictionary<string, string>? Metadata,
IReadOnlyList<FileEntryDocument> Files);
private sealed record FileEntryDocument(
string Path,
string Action,
string Sha256,
long Size,
string? ObjectPath,
string? ObjectKey,
IReadOnlyDictionary<string, string>? Metadata);
}

View File

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,375 @@
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 baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
? options.PreviousVersion
: options.BaselineVersion;
var fileMap = new FileMapDocument(
FormatVersion: "1.0",
DistributionId: distributionId,
FromVersion: options.PreviousVersion,
ToVersion: options.CurrentVersion,
Platform: options.Platform,
Channel: options.Channel,
PublishedAt: publishedAt,
Capabilities: ["file-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,
PublishedAt: publishedAt,
FileMapUrl: options.FileMapUrl,
FileMapSignatureUrl: options.FileMapSignatureUrl,
Components: fileMap.Components,
InstallerMirrors: installerMirrors,
Capabilities: ["file-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());
}
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,
Metadata: null));
continue;
}
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
var objectKey = CopyContentObject(current.FullPath, repoRoot, current.Sha256);
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
? null
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
entries.Add(new FileEntryDocument(
Path: path,
Action: action,
Sha256: current.Sha256,
Size: current.Size,
Mode: "file-object",
ObjectKey: objectKey,
ObjectUrl: objectUrl,
Metadata: new Dictionary<string, string> { ["mode"] = "file-object" }));
}
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,
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 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 Platform,
string Channel,
DateTimeOffset PublishedAt,
IReadOnlyList<string> Capabilities,
IReadOnlyList<ComponentDocument> Components,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record DistributionDocument(
string DistributionId,
string Version,
string Channel,
string Platform,
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,
IReadOnlyDictionary<string, string>? Metadata);
private sealed record InstallerMirrorDocument(
string Platform,
string Arch,
string? Url,
string? Name,
string? FileName,
string? Sha256,
long Size);
}

View File

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,237 @@
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

@@ -0,0 +1,57 @@
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

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

View File

@@ -0,0 +1,38 @@
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

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

View File

@@ -0,0 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record DdssManifest(
string FormatVersion,
string ReleaseTag,
DateTimeOffset GeneratedAt,
IReadOnlyList<DdssAssetEntry> Assets);

View File

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

View File

@@ -0,0 +1,11 @@
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

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

View File

@@ -0,0 +1,14 @@
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

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

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,9 @@
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

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

View File

@@ -0,0 +1,14 @@
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

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

View File

@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Plonds.Shared</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,25 @@
namespace Plonds.Shared;
public static class PlondsConstants
{
public const string ProtocolName = "PLONDS";
public const string ProtocolVersion = "1.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 FileObjectMode = "file-object";
public const string CompressedObjectMode = "compressed-object";
public const string BinaryPatchMode = "binary-patch";
public static readonly string[] SupportedFileModes =
[
FileObjectMode,
CompressedObjectMode,
BinaryPatchMode
];
}

View File

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

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Plonds.Core\Plonds.Core.csproj" />
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,221 @@
using Plonds.Core.Publishing;
using Plonds.Core.Security;
return await PlondsCli.RunAsync(args);
internal static class PlondsCli
{
public static Task<int> RunAsync(string[] args)
{
if (args.Length == 0)
{
PrintUsage();
return Task.FromResult(1);
}
var command = args[0].Trim().ToLowerInvariant();
var options = ParseOptions(args.Skip(1).ToArray());
try
{
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);
return Task.FromResult(0);
case "build-ddss":
RunBuildDdss(options);
return Task.FromResult(0);
default:
Console.Error.WriteLine($"Unknown command: {command}");
PrintUsage();
return Task.FromResult(1);
}
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return Task.FromResult(1);
}
}
private static void RunGenerate(Dictionary<string, string> options)
{
var generator = new PlondsGenerator();
var result = generator.Generate(new PlondsGenerateOptions(
CurrentVersion: Require(options, "current-version"),
CurrentDirectory: Require(options, "current-dir"),
Platform: Require(options, "platform"),
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")));
foreach (var result in results)
{
Console.WriteLine($"{result.Platform}: {result.DistributionId}");
}
}
private static void RunPackPayload(Dictionary<string, string> options)
{
var sourceDirectory = Require(options, "source-dir");
var outputZip = Require(options, "output-zip");
PayloadUtilities.CreatePayloadZip(sourceDirectory, outputZip);
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));
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 RunBuildDdss(Dictionary<string, string> options)
{
var builder = new DdssManifestBuilder();
var manifestPath = builder.Build(new DdssBuildOptions(
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);
for (var index = 0; index < args.Length; index++)
{
var token = args[index];
if (!token.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = token[2..];
var value = index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal)
? args[++index]
: "true";
result[key] = value;
}
return result;
}
private static string Require(IReadOnlyDictionary<string, string> options, string key)
{
if (options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
throw new InvalidOperationException($"Missing required option --{key}");
}
private static string? Get(IReadOnlyDictionary<string, string> options, string key, string? defaultValue = null)
{
return options.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: defaultValue;
}
private static void PrintUsage()
{
Console.WriteLine("PLONDS Tool");
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]");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
Console.WriteLine(" build-ddss --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>]");
}
}

View File

@@ -0,0 +1,87 @@
param(
[Parameter(Mandatory = $true)]
[string]$CurrentVersion,
[Parameter(Mandatory = $true)]
[string]$CurrentDir,
[Parameter(Mandatory = $true)]
[string]$Platform,
[Parameter(Mandatory = $true)]
[string]$OutputDir,
[Parameter(Mandatory = $false)]
[string]$PreviousVersion = "",
[Parameter(Mandatory = $false)]
[string]$PreviousDir = "",
[Parameter(Mandatory = $false)]
[string]$Channel = "stable",
[Parameter(Mandatory = $false)]
[string]$DistributionId = "",
[Parameter(Mandatory = $false)]
[string]$RepoBaseUrl = "",
[Parameter(Mandatory = $false)]
[string]$FileMapUrl = "",
[Parameter(Mandatory = $false)]
[string]$FileMapSignatureUrl = "",
[Parameter(Mandatory = $false)]
[string]$InstallerDirectory = "",
[Parameter(Mandatory = $false)]
[string]$InstallerBaseUrl = ""
)
$ErrorActionPreference = "Stop"
$toolProject = Join-Path $PSScriptRoot "..\PenguinLogisticsOnlineNetworkDistributionSystem\src\Plonds.Tool\Plonds.Tool.csproj"
if (-not (Test-Path -LiteralPath $toolProject)) {
throw "PLONDS tool project not found: $toolProject"
}
$arguments = @(
"run",
"--project", $toolProject,
"--",
"generate",
"--current-version", $CurrentVersion,
"--current-dir", $CurrentDir,
"--platform", $Platform,
"--output-dir", $OutputDir,
"--previous-version", $(if ([string]::IsNullOrWhiteSpace($PreviousVersion)) { "0.0.0" } else { $PreviousVersion }),
"--channel", $Channel
)
if (-not [string]::IsNullOrWhiteSpace($PreviousDir)) {
$arguments += @("--previous-dir", $PreviousDir)
}
if (-not [string]::IsNullOrWhiteSpace($DistributionId)) {
$arguments += @("--distribution-id", $DistributionId)
}
if (-not [string]::IsNullOrWhiteSpace($RepoBaseUrl)) {
$arguments += @("--repo-base-url", $RepoBaseUrl)
}
if (-not [string]::IsNullOrWhiteSpace($FileMapUrl)) {
$arguments += @("--file-map-url", $FileMapUrl)
}
if (-not [string]::IsNullOrWhiteSpace($FileMapSignatureUrl)) {
$arguments += @("--file-map-signature-url", $FileMapSignatureUrl)
}
if (-not [string]::IsNullOrWhiteSpace($InstallerDirectory)) {
$arguments += @("--installer-directory", $InstallerDirectory)
}
if (-not [string]::IsNullOrWhiteSpace($InstallerBaseUrl)) {
$arguments += @("--installer-base-url", $InstallerBaseUrl)
}
& dotnet @arguments
if ($LASTEXITCODE -ne 0) {
throw "PLONDS generate command failed."
}

1044
scripts/Publish-Plonds.ps1 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
param(
param(
[Parameter(Mandatory = $true)]
[string]$FilesJsonPath,
@@ -11,46 +11,16 @@ param(
$ErrorActionPreference = "Stop"
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw "Sign-FileMap.ps1 requires PowerShell 7 or newer."
}
if (-not (Test-Path -LiteralPath $FilesJsonPath)) {
throw "Manifest file not found: $FilesJsonPath"
}
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
throw "Private key file not found: $PrivateKeyPath"
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$OutputPath = "$FilesJsonPath.sig"
}
$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path
$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath)
$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
throw "Private key PEM is empty: $PrivateKeyPath"
$toolProject = Join-Path $PSScriptRoot "..\PenguinLogisticsOnlineNetworkDistributionSystem\src\Plonds.Tool\Plonds.Tool.csproj"
if (-not (Test-Path -LiteralPath $toolProject)) {
throw "PLONDS tool project not found: $toolProject"
}
$rsa = [System.Security.Cryptography.RSA]::Create()
try {
$rsa.ImportFromPem($privateKeyPem)
$signatureBytes = $rsa.SignData(
$manifestBytes,
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
)
& dotnet run --project $toolProject -- sign --manifest $FilesJsonPath --private-key $PrivateKeyPath --output $OutputPath
if ($LASTEXITCODE -ne 0) {
throw "PLONDS sign command failed."
}
finally {
$rsa.Dispose()
}
$signatureBase64 = [Convert]::ToBase64String($signatureBytes)
[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII)
Write-Host "Signed manifest file."
Write-Host "Manifest: $FilesJsonPath"
Write-Host "Signature: $OutputPath"