mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat.Penguin Logistics Online Network Distribution System
This commit is contained in:
501
.github/workflows/release.yml
vendored
501
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
93
PenguinLogisticsOnlineNetworkDistributionSystem/README.md
Normal file
93
PenguinLogisticsOnlineNetworkDistributionSystem/README.md
Normal 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`.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Plonds": {
|
||||
"StorageRoot": "sample-data",
|
||||
"MetaRoot": "meta",
|
||||
"ApiBasePath": "/api/plonds/v1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Plonds.Shared\Plonds.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Plonds.Shared.Models;
|
||||
|
||||
public sealed record PlondsSignatureDescriptor(
|
||||
string Algorithm,
|
||||
string KeyId,
|
||||
string Signature);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Plonds.Shared</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Plonds.Shared;
|
||||
|
||||
public enum PlondsFileOperation
|
||||
{
|
||||
Add,
|
||||
Replace,
|
||||
Reuse,
|
||||
Delete
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>]");
|
||||
}
|
||||
}
|
||||
87
scripts/Generate-PlondsArtifacts.ps1
Normal file
87
scripts/Generate-PlondsArtifacts.ps1
Normal 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
301
scripts/Publish-Plonds.ps1
Normal 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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user