feat.Penguin Logistics Online Network Distribution System

This commit is contained in:
lincube
2026-04-20 23:28:11 +08:00
parent 3f927c41c8
commit a31ae3cd58
47 changed files with 2446 additions and 822 deletions

View File

@@ -1,4 +1,4 @@
name: Release
name: Release
on:
push:
@@ -127,7 +127,7 @@ jobs:
exit 1
}
# 鏄剧ず鍙戝竷缁撴灉
# 閺勫墽銇氶崣鎴濈缂佹挻鐏?
Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
@@ -712,25 +712,18 @@ jobs:
if-no-files-found: error
retention-days: 30
publish-pdc:
needs: [ prepare, build-windows, build-linux, build-macos ]
publish-plonds:
needs: [ prepare, build-windows, build-linux ]
runs-on: ubuntu-latest
permissions:
contents: read
env:
VERSION: ${{ needs.prepare.outputs.version }}
PRIMARY_VERSION: ${{ needs.prepare.outputs.version }}
PDCC_primaryVersion: ${{ needs.prepare.outputs.version }}
PDCC_version: ${{ needs.prepare.outputs.version }}
PDC_CLIENT_VERSION: ${{ vars.PDC_CLIENT_VERSION || '1.0.1.0' }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION }}
PDC_ENDPOINT: ${{ vars.PDC_ENDPOINT }}
PDC_TOKEN: ${{ secrets.PDC_TOKEN }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
PDC_SIGNING_KEY_PS: ${{ secrets.PDC_SIGNING_KEY_PS }}
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
@@ -738,461 +731,80 @@ jobs:
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
AWS_EC2_METADATA_DISABLED: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Download payload artifacts
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Download app payload artifacts
uses: actions/download-artifact@v4
with:
path: payload-artifacts
path: artifacts/app-payload
pattern: app-payload-*
- name: Download installer artifacts
uses: actions/download-artifact@v4
with:
path: installer-artifacts
path: artifacts/installers
pattern: installer-*
- name: Prepare PDC environment
- name: Prepare signing key
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
function Resolve-PgpPrivateKey([string]$value) {
if ([string]::IsNullOrWhiteSpace($value)) {
return $null
}
$trimmed = $value.Trim()
if ($trimmed -match '-----BEGIN PGP PRIVATE KEY BLOCK-----') {
return $trimmed
}
try {
$decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($trimmed)).Trim()
if ($decoded -match '-----BEGIN PGP PRIVATE KEY BLOCK-----') {
return $decoded
}
}
catch {
}
return $trimmed
$key = $env:PLONDS_SIGNING_KEY
if ([string]::IsNullOrWhiteSpace($key)) {
$key = $env:UPDATE_PRIVATE_KEY_PEM
}
if ([string]::IsNullOrWhiteSpace($key)) {
throw "Missing PLONDS_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM."
}
if ([string]::IsNullOrWhiteSpace($env:S3_ENDPOINT) -or
[string]::IsNullOrWhiteSpace($env:S3_BUCKET)) {
throw "Missing required S3 variables."
}
$keyPath = Join-Path $PWD "update-private-key.pem"
[System.IO.File]::WriteAllText($keyPath, $key, [System.Text.Encoding]::ASCII)
Add-Content -Path $env:GITHUB_ENV -Value "UPDATE_PRIVATE_KEY_PATH=$keyPath"
$resolvedSigningKey = Resolve-PgpPrivateKey $env:PDC_SIGNING_KEY
if ([string]::IsNullOrWhiteSpace($resolvedSigningKey)) {
$resolvedSigningKey = Resolve-PgpPrivateKey $env:UPDATE_PRIVATE_KEY_PEM
}
if ([string]::IsNullOrWhiteSpace($resolvedSigningKey)) {
throw "Missing PDC_SIGNING_KEY (PGP private key)."
}
if ($resolvedSigningKey -notmatch '-----BEGIN PGP PRIVATE KEY BLOCK-----') {
throw "PDC signing key format is invalid. Please provide armored OpenPGP private key in PDC_SIGNING_KEY."
}
Add-Content -Path $env:GITHUB_ENV -Value "PDC_SIGNING_KEY<<EOF`n$resolvedSigningKey`nEOF"
- name: Probe S3 access
if: ${{ env.S3_ENDPOINT != '' && env.S3_BUCKET != '' && env.S3_ACCESS_KEY != '' && env.S3_SECRET_KEY != '' }}
shell: bash
run: |
set -euo pipefail
aws --endpoint-url "$S3_ENDPOINT" --region "$S3_REGION" s3 ls "s3://$S3_BUCKET" --only-show-errors >/dev/null
echo "S3 access probe succeeded for $S3_BUCKET"
$workRoot = Join-Path $PWD "pdc-work"
if (Test-Path $workRoot) {
Remove-Item -LiteralPath $workRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
$template = Get-Content -Path "phainon.yml" -Raw
$resolved = $template `
-replace '__FILE_REPO_ROOT__', "$($env:S3_ENDPOINT.TrimEnd('/'))/$($env:S3_BUCKET)/lanmountain/update/repo/" `
-replace '__ARCHIVE_ROOT__', "$($env:S3_ENDPOINT.TrimEnd('/'))/$($env:S3_BUCKET)/lanmountain/update/archive"
Set-Content -Path (Join-Path $workRoot "phainon.resolved.yml") -Value $resolved -NoNewline
python3 -m pip install --user --upgrade awscli
Add-Content -Path $env:GITHUB_PATH -Value "$HOME/.local/bin"
- name: Verify S3 credentials and endpoint
- name: Build PLONDS assets
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
function Invoke-AwsChecked([string[]]$Arguments) {
& aws @Arguments
if ($LASTEXITCODE -ne 0) {
throw "aws command failed: aws $($Arguments -join ' ')"
}
}
./scripts/Publish-Plonds.ps1 `
-Version $env:VERSION `
-AppArtifactsRoot (Join-Path $PWD "artifacts/app-payload") `
-InstallerArtifactsRoot (Join-Path $PWD "artifacts/installers") `
-OutputDir (Join-Path $PWD "plonds-output") `
-PrivateKeyPath $env:UPDATE_PRIVATE_KEY_PATH `
-Channel "stable" `
-S3Endpoint $env:S3_ENDPOINT `
-S3Bucket $env:S3_BUCKET `
-S3Region $env:S3_REGION
$probeDir = Join-Path $PWD "pdc-work"
New-Item -ItemType Directory -Path $probeDir -Force | Out-Null
$probeFile = Join-Path $probeDir "s3-probe.txt"
Set-Content -Path $probeFile -Value "lanmountain pdc probe $(Get-Date -Format o)" -NoNewline
$probeKey = "lanmountain/update/probe/$($env:GITHUB_RUN_ID)-$($env:GITHUB_RUN_ATTEMPT).txt"
Invoke-AwsChecked @("--endpoint-url", "$env:S3_ENDPOINT", "--region", "$env:S3_REGION", "s3", "cp", $probeFile, "s3://$env:S3_BUCKET/$probeKey", "--only-show-errors")
Invoke-AwsChecked @("--endpoint-url", "$env:S3_ENDPOINT", "--region", "$env:S3_REGION", "s3", "rm", "s3://$env:S3_BUCKET/$probeKey", "--only-show-errors")
Write-Host "S3 probe succeeded."
- name: Bootstrap PDC Endpoint and Token
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$endpoint = $env:PDC_ENDPOINT
if ([string]::IsNullOrWhiteSpace($endpoint)) {
$endpoint = "http://127.0.0.1:18765"
}
$token = $env:PDC_TOKEN
if ([string]::IsNullOrWhiteSpace($token)) {
$token = "lmd-pdc-local-token"
}
Add-Content -Path $env:GITHUB_ENV -Value "PDC_ENDPOINT=$endpoint"
Add-Content -Path $env:GITHUB_ENV -Value "PDC_TOKEN=$token"
Write-Host "Using PDC endpoint: $endpoint"
- name: Start Local PDC Mock (Fallback)
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($env:PDC_ENDPOINT)) {
throw "PDC_ENDPOINT is empty after bootstrap."
}
$uri = [Uri]$env:PDC_ENDPOINT
$isLocalHost = $uri.Host -eq "127.0.0.1" -or $uri.Host -eq "localhost"
if (-not $isLocalHost) {
Write-Host "Using external PDC endpoint: $($env:PDC_ENDPOINT)"
exit 0
}
if ([string]::IsNullOrWhiteSpace($env:PDC_TOKEN)) {
throw "PDC_TOKEN is empty after bootstrap."
}
$port = if ($uri.Port -gt 0) { $uri.Port } else { 18765 }
$dataDir = Join-Path $PWD "pdc-output/mock-pdc"
$workDir = Join-Path $PWD "pdc-work"
$logPath = Join-Path $workDir "pdc-mock.out.log"
$errLogPath = Join-Path $workDir "pdc-mock.err.log"
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
if (Test-Path $logPath) {
Remove-Item -LiteralPath $logPath -Force
}
if (Test-Path $errLogPath) {
Remove-Item -LiteralPath $errLogPath -Force
}
$args = @(
"scripts/pdc-mock-server.py",
"--host", "127.0.0.1",
"--port", $port.ToString(),
"--token", $env:PDC_TOKEN,
"--data-dir", $dataDir
)
$process = Start-Process -FilePath "python3" -ArgumentList $args -PassThru -RedirectStandardOutput $logPath -RedirectStandardError $errLogPath
if (-not $process) {
throw "Failed to launch PDC mock server."
}
$healthUrl = "http://127.0.0.1:$port/healthz"
$ready = $false
for ($i = 0; $i -lt 20; $i++) {
Start-Sleep -Seconds 1
try {
$response = Invoke-WebRequest -Uri $healthUrl -Method Get -TimeoutSec 2
if ($response.StatusCode -eq 200) {
$ready = $true
break
}
}
catch {
}
}
if (-not $ready) {
if (Test-Path $logPath) {
Write-Host "===== pdc-mock stdout ====="
Get-Content -LiteralPath $logPath -ErrorAction SilentlyContinue | Write-Host
}
if (Test-Path $errLogPath) {
Write-Host "===== pdc-mock stderr ====="
Get-Content -LiteralPath $errLogPath -ErrorAction SilentlyContinue | Write-Host
}
throw "PDC mock server did not become ready in time. See $logPath and $errLogPath."
}
Write-Host "Local PDC mock is running at http://127.0.0.1:$port"
- name: Install PDCC
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./scripts/Install-Pdcc.ps1 -Repository "ClassIsland/PhainonDistributionCenter" -Version "$env:PDC_CLIENT_VERSION" -OutputDir "./pdcc"
- name: Publish with PDCC
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# Map CI vars to the naming convention expected by PDCC tooling.
$env:S3_Endpoint = $env:S3_ENDPOINT
$env:S3_Bucket = $env:S3_BUCKET
$env:S3_Region = $env:S3_REGION
$env:PDC_Endpoint = $env:PDC_ENDPOINT
$env:PDC_Token = $env:PDC_TOKEN
$env:S3_AccessKey = $env:S3_ACCESS_KEY
$env:S3_SecretKey = $env:S3_SECRET_KEY
$signingKeyPs = $env:PDC_SIGNING_KEY_PS
if ([string]::IsNullOrWhiteSpace($signingKeyPs)) {
# Keep a non-empty value so PDCC required-env check passes on Linux runners.
$signingKeyPs = " "
}
$env:PDC_SigningKeyPs = $signingKeyPs
# Map config variables with exact names required by phainon placeholders.
$env:PDCC_version = $env:VERSION
$env:PDCC_primaryVersion = $env:PRIMARY_VERSION
$signingKey = $env:PDC_SIGNING_KEY
if ([string]::IsNullOrWhiteSpace($signingKey)) {
$signingKey = $env:UPDATE_PRIVATE_KEY_PEM
}
if ([string]::IsNullOrWhiteSpace($signingKey)) {
throw "Missing PDC signing key: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM."
}
if ($signingKey -notmatch '-----BEGIN PGP PRIVATE KEY BLOCK-----') {
throw "PDC signing key is not an armored OpenPGP private key."
}
$env:PDC_SigningKey = $signingKey
$workDir = Join-Path $PWD "pdc-work"
$stageRoot = Join-Path $PWD "pdc-stage"
$payloadRoot = Join-Path $PWD "payload-artifacts"
$installerRoot = Join-Path $PWD "installer-artifacts"
$outRoot = Join-Path $PWD "pdc-output"
$publishRoot = Join-Path $outRoot "published"
$client = Join-Path $PWD "pdcc/PhainonDistributionCenter.Client"
$config = Join-Path $workDir "phainon.resolved.yml"
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
if (-not (Test-Path -LiteralPath $config)) {
throw "Resolved PDCC config was not found: $config"
}
if (-not (Test-Path -LiteralPath $client)) {
throw "PDCC client was not found: $client"
}
if (Test-Path $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force
}
if (Test-Path $outRoot) {
Remove-Item -LiteralPath $outRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $stageRoot -Force | Out-Null
New-Item -ItemType Directory -Path $outRoot -Force | Out-Null
New-Item -ItemType Directory -Path $publishRoot -Force | Out-Null
$payloadArtifacts = Get-ChildItem -LiteralPath $payloadRoot -Directory
if (-not $payloadArtifacts) {
throw "No payload artifacts were downloaded."
}
$installerArtifacts = Get-ChildItem -LiteralPath $installerRoot -Directory
if (-not $installerArtifacts) {
throw "No installer artifacts were downloaded."
}
foreach ($installerArtifact in $installerArtifacts) {
$stagedInstallerDir = Join-Path $stageRoot "installers/$($installerArtifact.Name)"
./scripts/Prepare-PdccOut.ps1 -SourceDir $installerArtifact.FullName -OutputDir $stagedInstallerDir
}
foreach ($payloadArtifact in $payloadArtifacts) {
$platformKey = $payloadArtifact.Name -replace '^app-payload-', ''
$stagedPayloadDir = Join-Path $stageRoot "payloads/$platformKey"
./scripts/Prepare-PdccOut.ps1 -SourceDir $payloadArtifact.FullName -OutputDir $stagedPayloadDir
$parts = $platformKey.Split('-', 2)
if ($parts.Count -lt 2) {
throw "Invalid platform key format: $platformKey"
}
$os = $parts[0]
$arch = $parts[1]
$packageName = "LanMountainDesktop_app_${os}_${arch}_release_folder.zip"
$packagePath = Join-Path $publishRoot $packageName
Write-Host "Preparing PDCC subchannel package for $platformKey..."
& $client $config GenerateFileMap $stagedPayloadDir
if ($LASTEXITCODE -ne 0) {
throw "PDCC GenerateFileMap failed for $platformKey."
}
if (Test-Path $packagePath) {
Remove-Item -LiteralPath $packagePath -Force
}
Compress-Archive -Path (Join-Path $stagedPayloadDir '*') -DestinationPath $packagePath -Force
$packageSizeMb = [Math]::Round((Get-Item -LiteralPath $packagePath).Length / 1MB, 2)
Write-Host "Prepared package: $packageName ($packageSizeMb MB)"
}
$subchannelPackages = Get-ChildItem -LiteralPath $publishRoot -File -Filter "LanMountainDesktop_app_*_release_folder.zip"
if (-not $subchannelPackages) {
throw "No PDCC subchannel packages were prepared."
}
Write-Host "Publishing $($subchannelPackages.Count) subchannels in a single PDCC Publish run..."
$subchannelPackages | Sort-Object Name | ForEach-Object { Write-Host " - $($_.Name)" }
$publishStdOut = Join-Path $workDir "pdcc-publish.stdout.log"
$publishStdErr = Join-Path $workDir "pdcc-publish.stderr.log"
if (Test-Path $publishStdOut) {
Remove-Item -LiteralPath $publishStdOut -Force
}
if (Test-Path $publishStdErr) {
Remove-Item -LiteralPath $publishStdErr -Force
}
function Write-NewLogLines([string]$path, [ref]$lineCount, [string]$prefix) {
if (-not (Test-Path -LiteralPath $path)) {
return
}
$lines = Get-Content -LiteralPath $path -ErrorAction SilentlyContinue
if ($null -eq $lines) {
return
}
if ($lines -is [string]) {
$lines = @($lines)
}
if ($lines.Count -le $lineCount.Value) {
return
}
for ($i = $lineCount.Value; $i -lt $lines.Count; $i++) {
Write-Host "[$prefix] $($lines[$i])"
}
$lineCount.Value = $lines.Count
}
$publishArgs = @(
$config,
"Publish",
$env:PRIMARY_VERSION,
$env:VERSION,
$publishRoot
)
$publishTimeoutMinutes = 20
if (-not [string]::IsNullOrWhiteSpace($env:PDC_PUBLISH_TIMEOUT_MINUTES)) {
$parsedTimeout = 0
if ([int]::TryParse($env:PDC_PUBLISH_TIMEOUT_MINUTES, [ref]$parsedTimeout) -and $parsedTimeout -gt 0) {
$publishTimeoutMinutes = $parsedTimeout
}
}
$publishProcess = Start-Process `
-FilePath $client `
-ArgumentList $publishArgs `
-WorkingDirectory $publishRoot `
-RedirectStandardOutput $publishStdOut `
-RedirectStandardError $publishStdErr `
-PassThru
if (-not $publishProcess) {
throw "Failed to start PDCC Publish process."
}
Write-Host "PDCC Publish process started. PID=$($publishProcess.Id), timeout=${publishTimeoutMinutes}m"
$publishStart = Get-Date
$stdoutLineCount = 0
$stderrLineCount = 0
while (-not $publishProcess.HasExited) {
Start-Sleep -Seconds 15
$publishProcess.Refresh()
Write-NewLogLines -path $publishStdOut -lineCount ([ref]$stdoutLineCount) -prefix "pdcc"
Write-NewLogLines -path $publishStdErr -lineCount ([ref]$stderrLineCount) -prefix "pdcc-err"
$elapsed = (Get-Date) - $publishStart
Write-Host ("PDCC Publish heartbeat: elapsed={0:mm\\:ss}, pid={1}" -f $elapsed, $publishProcess.Id)
if ($elapsed.TotalMinutes -ge $publishTimeoutMinutes) {
Stop-Process -Id $publishProcess.Id -Force -ErrorAction SilentlyContinue
throw "PDCC Publish exceeded timeout of ${publishTimeoutMinutes} minutes."
}
}
Write-NewLogLines -path $publishStdOut -lineCount ([ref]$stdoutLineCount) -prefix "pdcc"
Write-NewLogLines -path $publishStdErr -lineCount ([ref]$stderrLineCount) -prefix "pdcc-err"
if ($publishProcess.ExitCode -ne 0) {
throw "PDCC Publish failed with exit code $($publishProcess.ExitCode)."
}
if (Test-Path (Join-Path $stageRoot "installers")) {
& aws --endpoint-url "$env:S3_ENDPOINT" --region "$env:S3_REGION" s3 sync (Join-Path $stageRoot "installers") "s3://$env:S3_BUCKET/lanmountain/update/installers/" --only-show-errors
if ($LASTEXITCODE -ne 0) {
throw "aws s3 sync failed for installer mirror upload."
}
}
- name: Upload PDC Assets
- name: Upload PLONDS assets
uses: actions/upload-artifact@v4
with:
name: pdc-assets
name: plonds-assets
path: |
pdc-output/published/**
plonds-output/release-assets/**
plonds-output/published/**
if-no-files-found: error
retention-days: 90
- name: Dump PDC Diagnostics
if: failure()
shell: pwsh
run: |
if (Test-Path "pdc-work/pdc-mock.out.log") {
Write-Host "===== pdc-mock stdout ====="
Get-Content "pdc-work/pdc-mock.out.log" -ErrorAction SilentlyContinue | Write-Host
}
if (Test-Path "pdc-work/pdc-mock.err.log") {
Write-Host "===== pdc-mock stderr ====="
Get-Content "pdc-work/pdc-mock.err.log" -ErrorAction SilentlyContinue | Write-Host
}
if (Test-Path "pdc-output/mock-pdc") {
Write-Host "===== pdc-mock captured payloads ====="
Get-ChildItem "pdc-output/mock-pdc" -Recurse -File | ForEach-Object {
Write-Host "--- $($_.FullName) ---"
Get-Content $_.FullName -ErrorAction SilentlyContinue | Write-Host
}
}
- name: Upload PDC Diagnostics Artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: pdc-diagnostics
path: |
pdc-work/pdc-mock*.log
pdc-work/pdcc-publish*.log
pdc-output/mock-pdc/**
if-no-files-found: ignore
retention-days: 30
github-release:
needs: [ prepare, build-windows, build-linux, build-macos, publish-pdc ]
needs: [ prepare, build-windows, build-linux, build-macos, publish-plonds ]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -1204,11 +816,11 @@ jobs:
path: artifacts/installers
pattern: installer-*
- name: Download PDC artifacts
- name: Download PLONDS artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/pdc
pattern: pdc-assets
path: artifacts/plonds
pattern: plonds-assets
- name: List artifacts structure
run: |
@@ -1226,7 +838,7 @@ jobs:
echo "Organizing artifacts..."
mkdir -p release-files
find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
find artifacts/pdc -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
find artifacts/plonds -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" -o -name "plonds-*.json" -o -name "plonds-*.json.sig" \) -exec cp -v {} release-files/ \;
echo ""
echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files"
@@ -1260,12 +872,12 @@ jobs:
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update Assets
### Incremental Update Assets`n - **plonds-filemap-windows-x64.json / plonds-filemap-windows-x64.json.sig**`n - **plonds-filemap-windows-x86.json / plonds-filemap-windows-x86.json.sig**`n - **plonds-filemap-linux-x64.json / plonds-filemap-linux-x64.json.sig**`n`n ### Legacy Fallback Assets
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
Existing users: Launcher will detect platform-matching signed assets and apply update on next startup.
Existing users: Host will prefer staged PLONDS payloads and keep the Launcher responsible for apply + rollback. Legacy signed file-map assets remain attached as a fallback path.
### Linux
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
@@ -1276,3 +888,4 @@ jobs:
See commits for changes.
token: ${{ secrets.GITHUB_TOKEN }}

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!;
@@ -470,13 +468,13 @@ internal sealed class UpdateEngineService
return;
}
var objectPath = ResolvePdcObjectPath(file);
var objectPath = ResolvePlondsObjectPath(file);
var objectBytes = File.ReadAllBytes(objectPath);
var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes;
File.WriteAllBytes(targetPath, restoredBytes);
}
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 +510,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 +565,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 +587,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 +634,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 +722,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 +746,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 +792,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 +811,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 +833,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 +849,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 +871,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 +914,7 @@ internal sealed class UpdateEngineService
metadata["component"] = componentName;
}
entry = new PdcFileEntry
entry = new PlondsFileEntry
{
Path = path,
Action = string.IsNullOrWhiteSpace(action) ? "replace" : action,
@@ -934,7 +932,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 +943,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"),
@@ -1028,7 +1026,7 @@ internal sealed class UpdateEngineService
}
}
private static PdcUpdateMetadata? LoadPdcUpdateMetadata(string path)
private static PlondsUpdateMetadata? LoadPlondsUpdateMetadata(string path)
{
if (!File.Exists(path))
{
@@ -1043,7 +1041,7 @@ internal sealed class UpdateEngineService
return null;
}
return JsonSerializer.Deserialize(text, AppJsonContext.Default.PdcUpdateMetadata);
return JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata);
}
catch
{
@@ -1051,7 +1049,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 +1058,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 +1105,7 @@ internal sealed class UpdateEngineService
}
/// <summary>
/// 全新安装场景:直接应用更新包作为首次部署
/// 闁稿繈鍔嶉弻濠勨偓鐟邦槼椤ュ﹪宕烽悜妯荤彲闁挎稒姘ㄥú鍧楀箳閵夈儳瀹夐柣顫妽濞插潡寮弶鍨樁濞达絾绮堢拹鐔革純閺嶎煈鍋ч梺顔哄妿鐠?
/// </summary>
private async Task<LauncherResult> ApplyInitialDeploymentAsync(
SignedFileMap fileMap,
@@ -1123,7 +1121,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 +1134,7 @@ internal sealed class UpdateEngineService
};
SaveSnapshot(snapshotPath, snapshot);
// 清理并解压更新包
// 婵炴挸鎳愰幃濠囩嵁閹澏鎺楀储鐎n偅绾柡鍌涙緲鐎?
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
@@ -1144,17 +1142,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 +1168,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 +1176,7 @@ internal sealed class UpdateEngineService
File.Delete(partialMarker);
}
// 清理更新包
snapshot.Status = "applied";
// 婵炴挸鎳愰幃濠囧即鐎涙ɑ鐓€闁? snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
@@ -1195,7 +1192,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 +1231,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 +1250,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 +1415,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 +1434,7 @@ internal sealed class UpdateEngineService
foreach (var directory in new[]
{
Path.Combine(_incomingRoot, PdcObjectsDirectoryName)
Path.Combine(_incomingRoot, PlondsObjectsDirectoryName)
})
{
try

View File

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

View File

@@ -11,15 +11,17 @@ 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.
/// Thin PLONDS client used by the host app.
/// The host keeps responsibility for checking and downloading updates; Launcher only applies staged payloads.
/// </summary>
public sealed class PdcReleaseUpdateService : IDisposable
public sealed class PlondsReleaseUpdateService : IDisposable
{
private const string DefaultApiBasePath = "/api/plonds/v1";
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public PdcReleaseUpdateService(HttpClient? httpClient = null)
public PlondsReleaseUpdateService(HttpClient? httpClient = null)
{
if (httpClient is null)
{
@@ -79,25 +81,40 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC endpoint is not configured.",
ErrorMessage: "PLONDS 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 apiBasePath = ResolveApiBasePath();
var metadataUrl = BuildApiUrl(endpoint, apiBasePath, "metadata");
var metadata = await GetJsonNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var channelId = ResolveChannelId(metadata, includePrerelease);
if (string.IsNullOrWhiteSpace(channelId))
{
channelId = includePrerelease ? "preview" : "stable";
}
var latestUrl = BuildUri(
var channelId = ResolveChannelId(includePrerelease);
var platform = ResolvePlatform();
var latestUrl = BuildApiUrl(
endpoint,
$"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
apiBasePath,
$"channels/{Uri.EscapeDataString(channelId)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
JsonElement latestNode;
try
{
latestNode = await GetJsonNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message.StartsWith("HTTP 204", StringComparison.OrdinalIgnoreCase))
{
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: normalizedCurrentVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: null,
ForceMode: isForce);
}
var latestVersionText = ReadString(latestNode, "version") ?? "-";
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
@@ -109,7 +126,7 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution version is invalid.",
ErrorMessage: "PLONDS latest distribution version is invalid.",
ForceMode: isForce);
}
@@ -123,7 +140,7 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution id is missing.",
ErrorMessage: "PLONDS latest distribution id is missing.",
ForceMode: isForce);
}
@@ -141,15 +158,15 @@ public sealed class PdcReleaseUpdateService : IDisposable
ForceMode: false);
}
var subChannel = ResolveSubChannel();
var distributionUrl = BuildUri(
var distributionUrl = BuildApiUrl(
endpoint,
$"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}");
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
apiBasePath,
$"distributions/{Uri.EscapeDataString(distributionId)}");
var distributionNode = await GetJsonNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
var assets = ResolveAssets(distributionNode);
var pdcPayload = ResolvePdcPayload(distributionNode, distributionId, channelId, subChannel);
if (assets.Count == 0 && !HasPdcPayload(pdcPayload))
var assets = ResolveInstallerAssets(distributionNode);
var payload = ResolvePlondsPayload(distributionNode, distributionId, channelId, platform);
if (assets.Count == 0 && !HasPlondsPayload(payload))
{
return new UpdateCheckResult(
Success: false,
@@ -158,18 +175,18 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC distribution response does not expose downloadable update assets.",
ErrorMessage: "PLONDS distribution response does not expose downloadable update assets.",
ForceMode: isForce);
}
var publishedAt = ParsePublishedAt(distributionNode) ?? DateTimeOffset.UtcNow;
var release = new GitHubReleaseInfo(
TagName: $"v{latestVersionText}",
Name: $"PDC Distribution {latestVersionText}",
Name: $"PLONDS Distribution {latestVersionText}",
IsPrerelease: includePrerelease,
IsDraft: false,
PublishedAt: DateTimeOffset.UtcNow,
PublishedAt: publishedAt,
Assets: assets);
var preferredAsset = SelectPreferredInstallerAsset(assets);
return new UpdateCheckResult(
Success: true,
@@ -177,10 +194,10 @@ public sealed class PdcReleaseUpdateService : IDisposable
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
PreferredAsset: SelectPreferredInstallerAsset(assets),
ErrorMessage: null,
ForceMode: isForce,
PdcPayload: pdcPayload);
PlondsPayload: payload);
}
catch (OperationCanceledException)
{
@@ -195,12 +212,12 @@ public sealed class PdcReleaseUpdateService : IDisposable
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: $"PDC request failed: {ex.Message}",
ErrorMessage: $"PLONDS request failed: {ex.Message}",
ForceMode: isForce);
}
}
private async Task<JsonElement> GetContentNodeAsync(string url, CancellationToken cancellationToken)
private async Task<JsonElement> GetJsonNodeAsync(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var token = ResolveToken();
@@ -227,15 +244,39 @@ public sealed class PdcReleaseUpdateService : IDisposable
return root.Clone();
}
private static IReadOnlyList<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
private static IReadOnlyList<GitHubReleaseAsset> ResolveInstallerAssets(JsonElement distributionNode)
{
var assets = new List<GitHubReleaseAsset>();
if (distributionNode.ValueKind != JsonValueKind.Object)
if (TryGetPropertyIgnoreCase(distributionNode, "installerMirrors", out var installersNode) &&
installersNode.ValueKind == JsonValueKind.Array)
{
foreach (var installerNode in installersNode.EnumerateArray())
{
if (installerNode.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = ReadString(installerNode, "name");
var url = ReadString(installerNode, "url") ?? ReadString(installerNode, "downloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
}
var size = ReadInt64(installerNode, "size") ?? 0L;
var sha256 = ReadString(installerNode, "sha256");
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
}
}
if (assets.Count > 0)
{
return assets;
}
if (distributionNode.TryGetProperty("assets", out var assetsNode) &&
if (TryGetPropertyIgnoreCase(distributionNode, "assets", out var assetsNode) &&
assetsNode.ValueKind == JsonValueKind.Array)
{
foreach (var assetNode in assetsNode.EnumerateArray())
@@ -246,9 +287,9 @@ public sealed class PdcReleaseUpdateService : IDisposable
}
var name = ReadString(assetNode, "name");
var url = ReadString(assetNode, "url") ??
ReadString(assetNode, "downloadUrl") ??
ReadString(assetNode, "browserDownloadUrl");
var url = ReadString(assetNode, "url")
?? ReadString(assetNode, "downloadUrl")
?? ReadString(assetNode, "browserDownloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
@@ -260,43 +301,14 @@ public sealed class PdcReleaseUpdateService : IDisposable
}
}
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(
private static PlondsUpdatePayload ResolvePlondsPayload(
JsonElement distributionNode,
string distributionId,
string channelId,
string subChannel)
string platform)
{
var fileMapJson = ReadString(distributionNode, "fileMapJson");
var fileMapSignature = ReadString(distributionNode, "fileMapSignature");
@@ -305,17 +317,18 @@ public sealed class PdcReleaseUpdateService : IDisposable
?? ReadString(distributionNode, "manifestUrl");
var fileMapSignatureUrl = ReadString(distributionNode, "fileMapSignatureUrl")
?? ReadString(distributionNode, "signatureUrl");
return new PdcUpdatePayload(
return new PlondsUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: subChannel,
SubChannel: platform,
FileMapJson: fileMapJson,
FileMapSignature: fileMapSignature,
FileMapJsonUrl: fileMapJsonUrl,
FileMapSignatureUrl: fileMapSignatureUrl);
}
private static bool HasPdcPayload(PdcUpdatePayload payload)
private static bool HasPlondsPayload(PlondsUpdatePayload payload)
{
return !string.IsNullOrWhiteSpace(payload.FileMapJson)
|| !string.IsNullOrWhiteSpace(payload.FileMapJsonUrl);
@@ -336,6 +349,7 @@ public sealed class PdcReleaseUpdateService : IDisposable
Architecture.X86 => "x86",
_ => "x64"
};
return assets
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".exe", ".msi", archToken)))
.OrderByDescending(x => x.Score)
@@ -405,59 +419,15 @@ public sealed class PdcReleaseUpdateService : IDisposable
return score;
}
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
private static string ResolveChannelId(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";
return includePrerelease
? UpdateSettingsValues.ChannelPreview
: UpdateSettingsValues.ChannelStable;
}
private static string ResolveSubChannel()
private static string ResolvePlatform()
{
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()
@@ -474,43 +444,58 @@ public sealed class PdcReleaseUpdateService : IDisposable
_ => "x64"
};
return $"{os}_{arch}_release_folderClassic";
return $"{os}-{arch}";
}
private static string? ResolveEndpoint()
{
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT")
?? Environment.GetEnvironmentVariable("PDC_ENDPOINT");
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_ENDPOINT")
?? Environment.GetEnvironmentVariable("PLONDS_ENDPOINT");
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
}
private static string? ResolveToken()
{
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN")
?? Environment.GetEnvironmentVariable("PDC_TOKEN");
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_TOKEN")
?? Environment.GetEnvironmentVariable("PLONDS_TOKEN");
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
}
private static string BuildUri(string endpoint, string relativePath)
private static string ResolveApiBasePath()
{
return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}";
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PLONDS_API_BASE_PATH")
?? Environment.GetEnvironmentVariable("PLONDS_API_BASE_PATH");
if (string.IsNullOrWhiteSpace(configured))
{
return DefaultApiBasePath;
}
var normalized = configured.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal) ? normalized : "/" + normalized;
}
private static string BuildApiUrl(string endpoint, string apiBasePath, string relativePath)
{
return $"{endpoint.TrimEnd('/')}/{apiBasePath.Trim('/').TrimEnd('/')}/{relativePath.TrimStart('/')}";
}
private static string? ReadString(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String
? value.GetString()
: value.ToString();
: value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
? null
: value.ToString();
}
private static long? ReadInt64(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
{
return null;
}
@@ -526,6 +511,37 @@ public sealed class PdcReleaseUpdateService : IDisposable
: null;
}
private static DateTimeOffset? ParsePublishedAt(JsonElement node)
{
var text = ReadString(node, "publishedAt");
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value)
? value
: null;
}
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{
if (node.ValueKind == JsonValueKind.Object)
{
foreach (var property in node.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
}
value = default;
return false;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = 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,20 @@ 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}");
}
return isForce
@@ -1271,14 +1271,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 +1318,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;
}

View File

@@ -61,16 +61,16 @@ public sealed class UpdateWorkflowService
private const string SignedFileMapName = "files.json";
private const string SignedFileMapSignatureName = "files.json.sig";
private const string UpdateArchiveName = "update.zip";
private const string PdcFileMapName = "pdc-filemap.json";
private const string PdcFileMapSignatureName = "pdc-filemap.sig";
private const string PdcUpdateStateName = "pdc-update.json";
private const string PlondsFileMapName = "plonds-filemap.json";
private const string PlondsFileMapSignatureName = "plonds-filemap.sig";
private const string PlondsUpdateStateName = "plonds-update.json";
private static readonly HttpClient PdcHttpClient = new()
private static readonly HttpClient PlondsHttpClient = new()
{
Timeout = TimeSpan.FromMinutes(5)
};
private static readonly ResumableDownloadService PdcDownloadService = new(PdcHttpClient);
private static readonly ResumableDownloadService PlondsDownloadService = new(PlondsHttpClient);
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{
@@ -116,7 +116,7 @@ public sealed class UpdateWorkflowService
public static bool IsDeltaUpdateAvailable(UpdateCheckResult checkResult)
{
if (checkResult.PdcPayload is not null)
if (checkResult.PlondsPayload is not null)
{
return true;
}
@@ -139,14 +139,14 @@ public sealed class UpdateWorkflowService
return new UpdateDownloadResult(false, null, "No update available for delta download.");
}
if (checkResult.PdcPayload is null && checkResult.Release is null)
if (checkResult.PlondsPayload is null && checkResult.Release is null)
{
return new UpdateDownloadResult(false, null, "No update payload is available for delta download.");
}
if (checkResult.PdcPayload is not null)
if (checkResult.PlondsPayload is not null)
{
return await DownloadPdcDeltaUpdateAsync(checkResult, progress, cancellationToken);
return await DownloadPlondsDeltaUpdateAsync(checkResult, progress, cancellationToken);
}
var release = checkResult.Release;
@@ -243,15 +243,15 @@ public sealed class UpdateWorkflowService
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
}
private async Task<UpdateDownloadResult> DownloadPdcDeltaUpdateAsync(
private async Task<UpdateDownloadResult> DownloadPlondsDeltaUpdateAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
var payload = checkResult.PdcPayload;
var payload = checkResult.PlondsPayload;
if (payload is null)
{
return new UpdateDownloadResult(false, null, "PDC payload is missing.");
return new UpdateDownloadResult(false, null, "PLONDS payload is missing.");
}
var incomingDir = GetLauncherIncomingDirectory();
@@ -271,33 +271,33 @@ public sealed class UpdateWorkflowService
{
var state = _settingsFacade.Update.Get();
var downloadThreads = Math.Max(1, state.UpdateDownloadThreads);
var fileMapPath = Path.Combine(incomingDir, PdcFileMapName);
var signaturePath = Path.Combine(incomingDir, PdcFileMapSignatureName);
var updateStatePath = Path.Combine(incomingDir, PdcUpdateStateName);
var fileMapPath = Path.Combine(incomingDir, PlondsFileMapName);
var signaturePath = Path.Combine(incomingDir, PlondsFileMapSignatureName);
var updateStatePath = Path.Combine(incomingDir, PlondsUpdateStateName);
var fileMapJson = await EnsurePdcTextResourceAsync(
var fileMapJson = await EnsurePlondsTextResourceAsync(
payload.FileMapJson,
payload.FileMapJsonUrl,
fileMapPath,
cancellationToken);
var fileMapSignature = await EnsurePdcTextResourceAsync(
var fileMapSignature = await EnsurePlondsTextResourceAsync(
payload.FileMapSignature,
payload.FileMapSignatureUrl,
signaturePath,
cancellationToken);
var downloadEntries = ParsePdcDownloadEntries(fileMapJson);
var downloadEntries = ParsePlondsDownloadEntries(fileMapJson);
if (downloadEntries.Count == 0)
{
return new UpdateDownloadResult(false, null, "PDC file map does not contain downloadable objects.");
return new UpdateDownloadResult(false, null, "PLONDS file map does not contain downloadable objects.");
}
var expectedObjectCount = downloadEntries.Count;
var completedItems = 2;
progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2));
var objectResults = new List<PdcDownloadedObjectInfo>(expectedObjectCount);
var objectResults = new List<PlondsDownloadedObjectInfo>(expectedObjectCount);
var objectTargets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var totalSteps = expectedObjectCount + 2;
@@ -310,7 +310,7 @@ public sealed class UpdateWorkflowService
continue;
}
var destinationPath = GetPdcObjectDestinationPath(objectsDir, entry.ObjectHashHex);
var destinationPath = GetPlondsObjectDestinationPath(objectsDir, entry.ObjectHashHex);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(destinationDirectory))
{
@@ -319,10 +319,10 @@ public sealed class UpdateWorkflowService
if (File.Exists(destinationPath))
{
var existingHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken);
var existingHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++;
progress?.Report((double)completedItems / totalSteps);
continue;
@@ -330,7 +330,7 @@ public sealed class UpdateWorkflowService
}
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
var downloadResult = await PdcDownloadService.DownloadAsync(
var downloadResult = await PlondsDownloadService.DownloadAsync(
entry.DownloadUrl,
destinationPath,
downloadOptions,
@@ -339,22 +339,22 @@ public sealed class UpdateWorkflowService
if (!downloadResult.Success)
{
return new UpdateDownloadResult(false, null, $"Failed to download PDC object {entry.RelativePath}: {downloadResult.ErrorMessage}");
return new UpdateDownloadResult(false, null, $"Failed to download PLONDS object {entry.RelativePath}: {downloadResult.ErrorMessage}");
}
var actualHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken);
var actualHash = await ComputeFileSha256HexAsync(destinationPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(actualHash) &&
!string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
{
return new UpdateDownloadResult(false, null, $"PDC object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
return new UpdateDownloadResult(false, null, $"PLONDS object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
}
objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
objectResults.Add(new PlondsDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
completedItems++;
progress?.Report((double)completedItems / totalSteps);
}
var updateState = new PdcUpdateState(
var updateState = new PlondsUpdateState(
checkResult.LatestVersionText,
payload.DistributionId,
payload.ChannelId,
@@ -381,7 +381,7 @@ public sealed class UpdateWorkflowService
});
progress?.Report(1d);
AppLogger.Info("UpdateWorkflow", $"PDC update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
AppLogger.Info("UpdateWorkflow", $"PLONDS update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
return new UpdateDownloadResult(true, updateStatePath, null);
}
catch (OperationCanceledException)
@@ -390,7 +390,7 @@ public sealed class UpdateWorkflowService
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", "Failed to download PDC incremental payload.", ex);
AppLogger.Warn("UpdateWorkflow", "Failed to download PLONDS incremental payload.", ex);
return new UpdateDownloadResult(false, null, ex.Message);
}
}
@@ -414,20 +414,20 @@ public sealed class UpdateWorkflowService
// Incoming payload updates are identified by the local manifest or incoming directory path.
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.EndsWith(PdcUpdateStateName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.EndsWith(PdcFileMapName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.EndsWith(PdcFileMapSignatureName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.EndsWith(PlondsUpdateStateName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.EndsWith(PlondsFileMapName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.EndsWith(PlondsFileMapSignatureName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
}
private static string GetPdcObjectDestinationPath(string objectsDirectory, string objectHashHex)
private static string GetPlondsObjectDestinationPath(string objectsDirectory, string objectHashHex)
{
var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
var shard = normalizedHash.Length >= 2 ? normalizedHash[..2] : normalizedHash;
return Path.Combine(objectsDirectory, shard, normalizedHash);
}
private static async Task<string> EnsurePdcTextResourceAsync(
private static async Task<string> EnsurePlondsTextResourceAsync(
string? inlineContent,
string? sourceUrl,
string destinationPath,
@@ -441,25 +441,25 @@ public sealed class UpdateWorkflowService
if (string.IsNullOrWhiteSpace(sourceUrl))
{
throw new InvalidOperationException("PDC payload does not contain a file map source.");
throw new InvalidOperationException("PLONDS payload does not contain a file map source.");
}
var downloadResult = await PdcDownloadService.DownloadAsync(
var downloadResult = await PlondsDownloadService.DownloadAsync(
sourceUrl,
destinationPath,
cancellationToken: cancellationToken);
if (!downloadResult.Success)
{
throw new InvalidOperationException($"Failed to download PDC file map resource: {downloadResult.ErrorMessage}");
throw new InvalidOperationException($"Failed to download PLONDS file map resource: {downloadResult.ErrorMessage}");
}
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
}
private static IReadOnlyList<PdcDownloadEntry> ParsePdcDownloadEntries(string fileMapJson)
private static IReadOnlyList<PlondsDownloadEntry> ParsePlondsDownloadEntries(string fileMapJson)
{
var entries = new List<PdcDownloadEntry>();
var entries = new List<PlondsDownloadEntry>();
if (string.IsNullOrWhiteSpace(fileMapJson))
{
return entries;
@@ -472,25 +472,56 @@ public sealed class UpdateWorkflowService
return entries;
}
if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode) ||
componentsNode.ValueKind != JsonValueKind.Object)
if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode))
{
return entries;
}
foreach (var component in componentsNode.EnumerateObject())
if (componentsNode.ValueKind == JsonValueKind.Object)
{
if (component.Value.ValueKind != JsonValueKind.Object)
foreach (var component in componentsNode.EnumerateObject())
{
continue;
}
if (component.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode) ||
filesNode.ValueKind != JsonValueKind.Object)
if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode))
{
continue;
}
AppendDownloadEntries(entries, component.Name, filesNode);
}
}
else if (componentsNode.ValueKind == JsonValueKind.Array)
{
foreach (var component in componentsNode.EnumerateArray())
{
continue;
}
if (component.ValueKind != JsonValueKind.Object)
{
continue;
}
var componentId = ReadStringIgnoreCase(component, "id")
?? ReadStringIgnoreCase(component, "name")
?? "app";
if (!TryGetPropertyIgnoreCase(component, "files", out var filesNode))
{
continue;
}
AppendDownloadEntries(entries, componentId, filesNode);
}
}
return entries;
}
private static void AppendDownloadEntries(ICollection<PlondsDownloadEntry> entries, string componentId, JsonElement filesNode)
{
if (filesNode.ValueKind == JsonValueKind.Object)
{
foreach (var fileEntry in filesNode.EnumerateObject())
{
if (fileEntry.Value.ValueKind != JsonValueKind.Object)
@@ -498,30 +529,82 @@ public sealed class UpdateWorkflowService
continue;
}
var downloadUrl = ReadStringIgnoreCase(fileEntry.Value, "archivedownloadurl")
?? ReadStringIgnoreCase(fileEntry.Value, "downloadurl")
?? ReadStringIgnoreCase(fileEntry.Value, "url");
var hashBytes = ReadByteArrayIgnoreCase(fileEntry.Value, "archivesha512")
?? ReadByteArrayIgnoreCase(fileEntry.Value, "filesha512");
if (string.IsNullOrWhiteSpace(downloadUrl) || hashBytes is null || hashBytes.Length == 0)
if (TryCreateDownloadEntry(componentId, fileEntry.Name, fileEntry.Value, out var entry))
{
continue;
entries.Add(entry);
}
}
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
entries.Add(new PdcDownloadEntry(
component.Name,
fileEntry.Name,
downloadUrl,
hashHex));
return;
}
if (filesNode.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var fileEntry in filesNode.EnumerateArray())
{
if (fileEntry.ValueKind != JsonValueKind.Object)
{
continue;
}
var relativePath = ReadStringIgnoreCase(fileEntry, "path");
if (TryCreateDownloadEntry(componentId, relativePath, fileEntry, out var entry))
{
entries.Add(entry);
}
}
}
private static bool TryCreateDownloadEntry(
string componentId,
string? relativePath,
JsonElement fileNode,
out PlondsDownloadEntry entry)
{
entry = default!;
var normalizedPath = string.IsNullOrWhiteSpace(relativePath)
? null
: relativePath.Trim();
var downloadUrl = ReadStringIgnoreCase(fileNode, "objecturl")
?? ReadStringIgnoreCase(fileNode, "downloadurl")
?? ReadStringIgnoreCase(fileNode, "archivedownloadurl")
?? ReadStringIgnoreCase(fileNode, "url");
var hashHex = ReadStringIgnoreCase(fileNode, "sha256")
?? ReadStringIgnoreCase(fileNode, "filesha256")
?? ReadStringIgnoreCase(fileNode, "contenthash");
if ((string.IsNullOrWhiteSpace(hashHex) || string.IsNullOrWhiteSpace(downloadUrl)) &&
TryGetPropertyIgnoreCase(fileNode, "hash", out var hashNode) &&
hashNode.ValueKind == JsonValueKind.Object)
{
var algorithm = ReadStringIgnoreCase(hashNode, "algorithm");
if (string.IsNullOrWhiteSpace(algorithm) ||
algorithm.Contains("sha256", StringComparison.OrdinalIgnoreCase))
{
hashHex ??= ReadStringIgnoreCase(hashNode, "value");
}
}
return entries;
if (string.IsNullOrWhiteSpace(normalizedPath) ||
string.IsNullOrWhiteSpace(downloadUrl) ||
string.IsNullOrWhiteSpace(hashHex))
{
return false;
}
entry = new PlondsDownloadEntry(
componentId,
normalizedPath,
downloadUrl,
NormalizeHashText(hashHex));
return true;
}
private static async Task<string?> ComputeFileSha512HexAsync(string filePath, CancellationToken cancellationToken)
private static async Task<string?> ComputeFileSha256HexAsync(string filePath, CancellationToken cancellationToken)
{
if (!File.Exists(filePath))
{
@@ -529,10 +612,22 @@ public sealed class UpdateWorkflowService
}
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var hashBytes = await SHA512.HashDataAsync(stream, cancellationToken);
var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string NormalizeHashText(string hash)
{
var normalized = hash.Trim();
var separator = normalized.IndexOf(':');
if (separator >= 0 && separator < normalized.Length - 1)
{
normalized = normalized[(separator + 1)..];
}
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
}
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
{
if (node.ValueKind == JsonValueKind.Object)
@@ -641,20 +736,20 @@ public sealed class UpdateWorkflowService
return true;
}
private sealed record PdcDownloadEntry(
private sealed record PlondsDownloadEntry(
string ComponentId,
string RelativePath,
string DownloadUrl,
string ObjectHashHex);
private sealed record PdcDownloadedObjectInfo(
private sealed record PlondsDownloadedObjectInfo(
string ComponentId,
string RelativePath,
string SourceUrl,
string ObjectHashHex,
string LocalPath);
private sealed record PdcUpdateState(
private sealed record PlondsUpdateState(
string VersionText,
string DistributionId,
string ChannelId,
@@ -665,7 +760,7 @@ public sealed class UpdateWorkflowService
DateTimeOffset DownloadedAtUtc,
string FileMapJson,
string FileMapSignature,
IReadOnlyList<PdcDownloadedObjectInfo> Objects);
IReadOnlyList<PlondsDownloadedObjectInfo> Objects);
private static bool TryResolveDeltaAssets(
IReadOnlyList<GitHubReleaseAsset> assets,
@@ -776,7 +871,7 @@ public sealed class UpdateWorkflowService
{
ArgumentNullException.ThrowIfNull(checkResult);
if (checkResult.PdcPayload is not null)
if (checkResult.PlondsPayload is not null)
{
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
}
@@ -837,7 +932,7 @@ public sealed class UpdateWorkflowService
{
ArgumentNullException.ThrowIfNull(checkResult);
if (checkResult.PdcPayload is not null)
if (checkResult.PlondsPayload is not null)
{
ClearPendingUpdate();
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
@@ -912,14 +1007,14 @@ public sealed class UpdateWorkflowService
if (IsPendingDeltaUpdate())
{
var pdcUpdatePath = pending.InstallerPath;
var pdcFileMapPath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PdcFileMapName);
var pdcSignaturePath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PdcFileMapSignatureName);
var pdcFileMapPath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PlondsFileMapName);
var pdcSignaturePath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PlondsFileMapSignatureName);
if (File.Exists(pdcUpdatePath) && File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
{
return new UpdateVerifyResult(true, true, null, null, null);
}
return new UpdateVerifyResult(false, false, null, null, "PDC update payload is incomplete.");
return new UpdateVerifyResult(false, false, null, null, "PLONDS update payload is incomplete.");
}
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
@@ -961,7 +1056,7 @@ public sealed class UpdateWorkflowService
{
// Always check for updates on startup (removed AutoCheckUpdates check)
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PdcPayload is null))
if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PlondsPayload is null))
{
return;
}

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 Skeleton
Penguin Logistics Online Network Distribution System, or PLONDS, is the standalone update-distribution skeleton for LanMountainDesktop.
This directory is intentionally isolated from the main app and Launcher. It contains only the new distribution protocol, a thin read-only API, and sample S3-style metadata files.
## Directory Layout
```text
PenguinLogisticsOnlineNetworkDistributionSystem/
README.md
src/
Plonds.Shared/
Plonds.Api/
sample-data/
meta/
channels/
stable/
windows-x64/
windows-x86/
linux-x64/
distributions/
```
## Projects
- `Plonds.Shared` provides protocol constants and models.
- `Plonds.Core` owns hashing, diffing, object-repo generation, manifest generation, signing, and publish orchestration.
- `Plonds.Tool` is the CI-facing CLI entrypoint. PowerShell should stay as a thin wrapper around this tool.
- `Plonds.Api` is a thin read-only API that reads metadata from a local folder laid out like S3.
## Architecture
PLONDS is intentionally built around a single C# implementation stack so the protocol and publish behavior do not drift across languages.
```text
Host App
-> checks updates, downloads objects, stages incoming payload
Launcher
-> verifies signature, applies file map, switches deployment, rolls back
PLONDS.Api
-> read-only metadata projection for clients
PLONDS.Tool
-> CI/release command surface
PLONDS.Core
-> hash/diff/object-repo/sign/publish implementation
PLONDS.Shared
-> protocol constants and DTOs
```
Rules for v1:
- Core protocol behavior should live in `Plonds.Core`, not in PowerShell scripts.
- `scripts/*.ps1` may remain only as thin wrappers for GitHub Actions and local convenience.
- Host keeps download responsibility.
- Launcher keeps apply, atomic switch, snapshot, and rollback responsibility.
## Storage Layout
The first version keeps one fixed object root:
```text
lanmountain/update/
repo/sha256/<prefix>/<hash>
meta/channels/<channel>/<platform>/latest.json
meta/distributions/<distributionId>.json
installers/<platform>/<version>/...
```
Planned but not enabled in v1:
```text
lanmountain/update/repo-compressed/<algo>/<prefix>/<hash>
lanmountain/update/patches/<algo>/<baseHash>/<targetHash>
```
## Public Endpoints
The API base path is `/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}`
## Local Run
```powershell
dotnet run --project src/Plonds.Api
```
By default the API reads metadata from `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,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,16 @@
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);

View File

@@ -0,0 +1,351 @@
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 = 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 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"
});
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" });
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,
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? FileName,
string? Sha256,
long Size);
}

View File

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

View File

@@ -0,0 +1,230 @@
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: previousVersion,
PreviousDirectory: previousDirectory,
Channel: options.Channel,
DistributionId: distributionId,
RepoBaseUrl: repoBaseUrl,
FileMapUrl: fileMapUrl,
FileMapSignatureUrl: fileMapSignatureUrl,
InstallerDirectory: installerSourceDirectory,
InstallerBaseUrl: installerBaseUrl));
_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,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,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,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,142 @@
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);
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")));
foreach (var result in results)
{
Console.WriteLine($"{result.Platform}: {result.DistributionId}");
}
}
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(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
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."
}

301
scripts/Publish-Plonds.ps1 Normal file
View File

@@ -0,0 +1,301 @@
param(
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[string]$AppArtifactsRoot,
[Parameter(Mandatory = $true)]
[string]$InstallerArtifactsRoot,
[Parameter(Mandatory = $true)]
[string]$OutputDir,
[Parameter(Mandatory = $true)]
[string]$PrivateKeyPath,
[Parameter(Mandatory = $false)]
[string]$Channel = "stable",
[Parameter(Mandatory = $false)]
[string]$S3Endpoint = "",
[Parameter(Mandatory = $false)]
[string]$S3Bucket = "",
[Parameter(Mandatory = $false)]
[string]$S3Region = ""
)
$ErrorActionPreference = "Stop"
function Get-PlatformConfigurations {
return @(
@{
Platform = "windows-x64"
ArtifactName = "app-payload-windows-x64"
},
@{
Platform = "windows-x86"
ArtifactName = "app-payload-windows-x86"
},
@{
Platform = "linux-x64"
ArtifactName = "app-payload-linux-x64"
}
)
}
function Resolve-AppDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$SearchRoot,
[Parameter(Mandatory = $true)]
[string]$Version
)
$preferred = Get-ChildItem -LiteralPath $SearchRoot -Recurse -Directory -Filter "app-$Version" -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($preferred) {
return $preferred.FullName
}
$fallback = Get-ChildItem -LiteralPath $SearchRoot -Recurse -Directory -Filter "app-*" -ErrorAction SilentlyContinue |
Sort-Object FullName |
Select-Object -First 1
return $fallback?.FullName
}
function Invoke-AwsSyncIfPossible {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[Parameter(Mandatory = $false)]
[switch]$IgnoreFailure
)
if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) {
return
}
& aws @Arguments
if ($LASTEXITCODE -ne 0 -and -not $IgnoreFailure) {
throw "aws command failed: aws $($Arguments -join ' ')"
}
}
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
throw "Private key file not found: $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"
}
$supportedPlatforms = Get-PlatformConfigurations
$publishedRoot = Join-Path $OutputDir "published"
$releaseAssetsRoot = Join-Path $OutputDir "release-assets"
$baselineRoot = Join-Path $OutputDir "_baselines"
New-Item -ItemType Directory -Path $publishedRoot -Force | Out-Null
New-Item -ItemType Directory -Path $releaseAssetsRoot -Force | Out-Null
New-Item -ItemType Directory -Path $baselineRoot -Force | Out-Null
foreach ($config in $supportedPlatforms) {
$platform = $config.Platform
$platformBaselineRoot = Join-Path $baselineRoot $platform
$baselineCurrentDir = Join-Path $platformBaselineRoot "current"
$baselineVersionPath = Join-Path $platformBaselineRoot "version.txt"
New-Item -ItemType Directory -Path $baselineCurrentDir -Force | Out-Null
if (-not [string]::IsNullOrWhiteSpace($S3Endpoint) -and -not [string]::IsNullOrWhiteSpace($S3Bucket)) {
Invoke-AwsSyncIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3", "sync",
"s3://$S3Bucket/lanmountain/update/baselines/$platform/current/",
$baselineCurrentDir,
"--only-show-errors"
) -IgnoreFailure
Invoke-AwsSyncIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3", "cp",
"s3://$S3Bucket/lanmountain/update/baselines/$platform/version.txt",
$baselineVersionPath,
"--only-show-errors"
) -IgnoreFailure
}
}
$repoBaseUrl = if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) {
$null
}
else {
"$($S3Endpoint.TrimEnd('/'))/$S3Bucket/lanmountain/update/repo/sha256"
}
$installerBaseUrl = if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) {
$null
}
else {
"$($S3Endpoint.TrimEnd('/'))/$S3Bucket/lanmountain/update/installers"
}
$legacySnapshots = @{}
foreach ($config in $supportedPlatforms) {
$platform = $config.Platform
$platformBaselineRoot = Join-Path $baselineRoot $platform
$baselineCurrentDir = Join-Path $platformBaselineRoot "current"
$baselineVersionPath = Join-Path $platformBaselineRoot "version.txt"
$snapshotRoot = Join-Path $platformBaselineRoot "previous-snapshot"
if (Test-Path -LiteralPath $snapshotRoot) {
Remove-Item -LiteralPath $snapshotRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $snapshotRoot -Force | Out-Null
$previousVersion = if (Test-Path -LiteralPath $baselineVersionPath) {
(Get-Content -LiteralPath $baselineVersionPath -Raw).Trim()
}
else {
"0.0.0"
}
$baselineHasContent = Get-ChildItem -LiteralPath $baselineCurrentDir -Force -ErrorAction SilentlyContinue | Select-Object -First 1
if ($baselineHasContent) {
Copy-Item -LiteralPath (Join-Path $baselineCurrentDir '*') -Destination $snapshotRoot -Recurse -Force
$snapshotDir = $snapshotRoot
}
else {
$snapshotDir = Join-Path $platformBaselineRoot "empty"
New-Item -ItemType Directory -Path $snapshotDir -Force | Out-Null
}
$legacySnapshots[$platform] = @{
PreviousVersion = $previousVersion
PreviousDir = $snapshotDir
}
}
$publishArguments = @(
"run",
"--project", $toolProject,
"--",
"publish",
"--version", $Version,
"--app-artifacts-root", $AppArtifactsRoot,
"--installer-artifacts-root", $InstallerArtifactsRoot,
"--output-dir", $publishedRoot,
"--private-key", $PrivateKeyPath,
"--baseline-root", $baselineRoot,
"--channel", $Channel
)
if (-not [string]::IsNullOrWhiteSpace($repoBaseUrl)) {
$publishArguments += @("--repo-base-url", $repoBaseUrl)
}
if (-not [string]::IsNullOrWhiteSpace($installerBaseUrl)) {
$publishArguments += @("--installer-base-url", $installerBaseUrl)
}
& dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "PLONDS publish command failed."
}
foreach ($config in $supportedPlatforms) {
$platform = $config.Platform
$artifactRoot = Join-Path $AppArtifactsRoot $config.ArtifactName
if (-not (Test-Path -LiteralPath $artifactRoot)) {
throw "App payload artifact root not found for ${platform}: $artifactRoot"
}
$currentAppDir = Resolve-AppDirectory -SearchRoot $artifactRoot -Version $Version
if ([string]::IsNullOrWhiteSpace($currentAppDir)) {
throw "Unable to locate app payload directory for $platform under $artifactRoot"
}
$distributionId = "plonds-$Version-$platform"
$manifestPath = Join-Path $publishedRoot "manifests/$distributionId/plonds-filemap.json"
$manifestSignaturePath = "$manifestPath.sig"
$legacyOutputDir = Join-Path $OutputDir "legacy/$platform"
New-Item -ItemType Directory -Path $legacyOutputDir -Force | Out-Null
$legacyState = $legacySnapshots[$platform]
& (Join-Path $PSScriptRoot "Generate-DeltaPackage.ps1") `
-PreviousVersion $legacyState.PreviousVersion `
-CurrentVersion $Version `
-PreviousDir $legacyState.PreviousDir `
-CurrentDir $currentAppDir `
-OutputDir $legacyOutputDir
if ($LASTEXITCODE -ne 0) {
throw "Generate-DeltaPackage.ps1 failed for $platform"
}
$legacyManifestPath = Join-Path $legacyOutputDir "files.json"
$legacySignaturePath = Join-Path $legacyOutputDir "files.json.sig"
& (Join-Path $PSScriptRoot "Sign-FileMap.ps1") `
-FilesJsonPath $legacyManifestPath `
-PrivateKeyPath $PrivateKeyPath `
-OutputPath $legacySignaturePath
if ($LASTEXITCODE -ne 0) {
throw "Failed to sign legacy manifest for $platform"
}
Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $releaseAssetsRoot "plonds-filemap-$platform.json") -Force
Copy-Item -LiteralPath $manifestSignaturePath -Destination (Join-Path $releaseAssetsRoot "plonds-filemap-$platform.json.sig") -Force
Copy-Item -LiteralPath (Join-Path $publishedRoot "meta/distributions/$distributionId.json") -Destination (Join-Path $releaseAssetsRoot "plonds-distribution-$platform.json") -Force
Copy-Item -LiteralPath (Join-Path $publishedRoot "meta/channels/$Channel/$platform/latest.json") -Destination (Join-Path $releaseAssetsRoot "plonds-latest-$platform.json") -Force
Copy-Item -LiteralPath $legacyManifestPath -Destination (Join-Path $releaseAssetsRoot "files-$platform.json") -Force
Copy-Item -LiteralPath $legacySignaturePath -Destination (Join-Path $releaseAssetsRoot "files-$platform.json.sig") -Force
Copy-Item -LiteralPath (Join-Path $legacyOutputDir "update.zip") -Destination (Join-Path $releaseAssetsRoot "update-$platform.zip") -Force
}
if (-not [string]::IsNullOrWhiteSpace($S3Endpoint) -and -not [string]::IsNullOrWhiteSpace($S3Bucket)) {
Invoke-AwsSyncIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3", "sync",
$publishedRoot,
"s3://$S3Bucket/lanmountain/update/",
"--only-show-errors"
)
foreach ($config in $supportedPlatforms) {
$platform = $config.Platform
$platformBaselineRoot = Join-Path $baselineRoot $platform
$baselineCurrentDir = Join-Path $platformBaselineRoot "current"
$baselineVersionPath = Join-Path $platformBaselineRoot "version.txt"
Invoke-AwsSyncIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3", "sync",
$baselineCurrentDir,
"s3://$S3Bucket/lanmountain/update/baselines/$platform/current/",
"--delete",
"--only-show-errors"
)
Invoke-AwsSyncIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3", "cp",
$baselineVersionPath,
"s3://$S3Bucket/lanmountain/update/baselines/$platform/version.txt",
"--only-show-errors"
)
}
}
Write-Host "PLONDS publish staging completed."
Write-Host "Published root: $publishedRoot"
Write-Host "Release assets: $releaseAssetsRoot"

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"