diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 911ef3e..6979949 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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</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 }} + diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 21b81ca..26b229a 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -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))] diff --git a/LanMountainDesktop.Launcher/Models/UpdateModels.cs b/LanMountainDesktop.Launcher/Models/UpdateModels.cs index cbabd89..e10c25b 100644 --- a/LanMountainDesktop.Launcher/Models/UpdateModels.cs +++ b/LanMountainDesktop.Launcher/Models/UpdateModels.cs @@ -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 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 Metadata { get; set; } = []; - public List Components { get; set; } = []; + public List Components { get; set; } = []; - public List Files { get; set; } = []; + public List 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 Metadata { get; set; } = []; - public List Files { get; set; } = []; + public List 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 Metadata { get; set; } = []; } -internal sealed class PdcHashDescriptor +internal sealed class PlondsHashDescriptor { public string? Algorithm { get; set; } diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index 7a7f1c6..fa0ee07 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -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 ApplyPendingPdcUpdateAsync( + private async Task 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(); - 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 candidates, string? value) + private void AddPlondsPathCandidates(ICollection 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 CollectPdcFileEntries(PdcFileMap fileMap) + private static List CollectPlondsFileEntries(PlondsFileMap fileMap) { - var files = new List(); + var files = new List(); 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 files) + private static void PopulatePlondsManifestFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection 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 files) + private static void ParsePlondsFilesNode(JsonElement filesNode, string? componentName, ICollection 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 } /// - /// 全新安装场景:直接应用更新包作为首次部署 + /// 闁稿繈鍔嶉弻濠勨偓鐟邦槼椤ュ﹪宕烽悜妯荤彲闁挎稒姘ㄥú鍧楀箳閵夈儳瀹夐柣顫妽濞插潡寮弶鍨樁濞达絾绮堢拹鐔革純閺嶎煈鍋ч梺顔哄妿鐠? /// private async Task 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 } /// - /// 应用初始部署文件(全新安装场景,不需要源目录) - /// + /// 閹煎瓨姊婚弫銈夊礆濠靛棭娼楅梺顔哄妿鐠佹煡寮崶锔筋偨闁挎稑鐗嗛崣蹇涘棘閺夎法鏆旈悷浣告噹濠р偓闁哄拋鍨界槐婵囩▔瀹ュ浠橀悷鏇氱劍缁噣鎯勯鑲╃Э闁? /// 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 diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs index 61ac365..4bd6c48 100644 --- a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -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, diff --git a/LanMountainDesktop/Services/PdcReleaseUpdateService.cs b/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs similarity index 67% rename from LanMountainDesktop/Services/PdcReleaseUpdateService.cs rename to LanMountainDesktop/Services/PlondsReleaseUpdateService.cs index c773a0f..6ce9387 100644 --- a/LanMountainDesktop/Services/PdcReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/PlondsReleaseUpdateService.cs @@ -11,15 +11,17 @@ using System.Threading.Tasks; namespace LanMountainDesktop.Services; /// -/// 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. /// -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 GetContentNodeAsync(string url, CancellationToken cancellationToken) + private async Task 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 ResolveAssets(JsonElement distributionNode) + private static IReadOnlyList ResolveInstallerAssets(JsonElement distributionNode) { var assets = new List(); - 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; diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 09d0d78..01abfc9 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -356,7 +356,7 @@ public interface IUpdateSettingsService void Save(UpdateSettingsState state); Task CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); Task ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default); - Task GetPdcUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default); + Task GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default); Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index afa8b18..5148356 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -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 GetPdcUpdatePayloadAsync( + public async Task 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 DownloadAssetAsync( @@ -891,7 +891,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl public void Dispose() { _githubReleaseUpdateService.Dispose(); - _pdcReleaseUpdateService.Dispose(); + _plondsReleaseUpdateService.Dispose(); } private async Task 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()? @@ -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; } diff --git a/LanMountainDesktop/Services/UpdateSettingsValues.cs b/LanMountainDesktop/Services/UpdateSettingsValues.cs index 3e17837..f55529b 100644 --- a/LanMountainDesktop/Services/UpdateSettingsValues.cs +++ b/LanMountainDesktop/Services/UpdateSettingsValues.cs @@ -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; } diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index 7de3ad9..70800e9 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -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 DownloadPdcDeltaUpdateAsync( + private async Task DownloadPlondsDeltaUpdateAsync( UpdateCheckResult checkResult, IProgress? 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(expectedObjectCount); + var objectResults = new List(expectedObjectCount); var objectTargets = new HashSet(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 EnsurePdcTextResourceAsync( + private static async Task 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 ParsePdcDownloadEntries(string fileMapJson) + private static IReadOnlyList ParsePlondsDownloadEntries(string fileMapJson) { - var entries = new List(); + var entries = new List(); 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 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 ComputeFileSha512HexAsync(string filePath, CancellationToken cancellationToken) + private static async Task 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 Objects); + IReadOnlyList Objects); private static bool TryResolveDeltaAssets( IReadOnlyList 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; } diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/Directory.Build.props b/PenguinLogisticsOnlineNetworkDistributionSystem/Directory.Build.props new file mode 100644 index 0000000..75dfbfc --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/Directory.Build.props @@ -0,0 +1,14 @@ + + + 0.1.0 + 0.1.0 + 0.1.0 + 0.1.0.0 + 0.1.0.0 + net10.0 + enable + enable + false + false + + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/README.md b/PenguinLogisticsOnlineNetworkDistributionSystem/README.md new file mode 100644 index 0000000..78bf0b1 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/README.md @@ -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// + meta/channels///latest.json + meta/distributions/.json + installers///... +``` + +Planned but not enabled in v1: + +```text +lanmountain/update/repo-compressed/// +lanmountain/update/patches/// +``` + +## 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`. diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/linux-x64/latest.json b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/linux-x64/latest.json new file mode 100644 index 0000000..18f1c73 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/linux-x64/latest.json @@ -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" +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/windows-x64/latest.json b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/windows-x64/latest.json new file mode 100644 index 0000000..e2d0fb8 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/windows-x64/latest.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" +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/windows-x86/latest.json b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/windows-x86/latest.json new file mode 100644 index 0000000..249943e --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/channels/stable/windows-x86/latest.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" +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-linux-x64.json b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-linux-x64.json new file mode 100644 index 0000000..b384b3b --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-linux-x64.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" + } +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-windows-x64.json b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-windows-x64.json new file mode 100644 index 0000000..4b5952f --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-windows-x64.json @@ -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" + } +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-windows-x86.json b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-windows-x86.json new file mode 100644 index 0000000..e3ebe76 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/sample-data/meta/distributions/plonds-0.8.5.2-windows-x86.json @@ -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" + } +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Configuration/PlondsApiOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Configuration/PlondsApiOptions.cs new file mode 100644 index 0000000..dafe670 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Configuration/PlondsApiOptions.cs @@ -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; +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Plonds.Api.csproj b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Plonds.Api.csproj new file mode 100644 index 0000000..8f46d4d --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Plonds.Api.csproj @@ -0,0 +1,10 @@ + + + Plonds.Api + + + + + + + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Program.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Program.cs new file mode 100644 index 0000000..63a9b01 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Program.cs @@ -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(builder.Configuration.GetSection("Plonds")); +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + return options; +}); +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService(); + 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(); + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Services/FileSystemPlondsManifestStore.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Services/FileSystemPlondsManifestStore.cs new file mode 100644 index 0000000..53e2cdb --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Services/FileSystemPlondsManifestStore.cs @@ -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 GetCatalogAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + + var channelsRoot = Path.Combine(_metaRootFullPath, "channels"); + var latest = new List(); + 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 + { + ["apiBasePath"] = PlondsConstants.DefaultApiBasePath + }); + + return Task.FromResult(catalog); + } + + public Task GetLatestAsync(string channel, string platform, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + return Task.FromResult(ReadLatestPointer(GetLatestPath(channel, platform))); + } + + public Task GetDistributionAsync(string distributionId, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + + var path = GetDistributionPath(distributionId); + if (!File.Exists(path)) + { + return Task.FromResult(null); + } + + var json = File.ReadAllText(path); + var distribution = JsonSerializer.Deserialize(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(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(); + + 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 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; + } + } +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Services/IPlondsManifestStore.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Services/IPlondsManifestStore.cs new file mode 100644 index 0000000..4ec24e7 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/Services/IPlondsManifestStore.cs @@ -0,0 +1,13 @@ +using Plonds.Shared.Models; + +namespace Plonds.Api.Services; + +public interface IPlondsManifestStore +{ + Task GetCatalogAsync(CancellationToken cancellationToken = default); + + Task GetLatestAsync(string channel, string platform, CancellationToken cancellationToken = default); + + Task GetDistributionAsync(string distributionId, CancellationToken cancellationToken = default); +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/appsettings.json b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/appsettings.json new file mode 100644 index 0000000..8559bb2 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Api/appsettings.json @@ -0,0 +1,8 @@ +{ + "Plonds": { + "StorageRoot": "sample-data", + "MetaRoot": "meta", + "ApiBasePath": "/api/plonds/v1" + } +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Plonds.Core.csproj b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Plonds.Core.csproj new file mode 100644 index 0000000..2301f4d --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Plonds.Core.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlatformPublishResult.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlatformPublishResult.cs new file mode 100644 index 0000000..326ccb0 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlatformPublishResult.cs @@ -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 InstallerFiles); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs new file mode 100644 index 0000000..1f44668 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerateOptions.cs @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs new file mode 100644 index 0000000..e6e7939 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsGenerator.cs @@ -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 { ["component"] = "app" }) + ], + Metadata: new Dictionary + { + ["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 { ["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 ScanDirectory(string? root) + { + var manifest = new Dictionary(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 BuildFileEntries( + Dictionary previousManifest, + Dictionary currentManifest, + string repoRoot, + string? repoBaseUrl) + { + var entries = new List(); + + 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 { ["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 BuildInstallerMirrors( + string platform, + string installerMirrorRoot, + string? installerSourceDirectory, + string? installerBaseUrl) + { + var result = new List(); + 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(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 Capabilities, + IReadOnlyList Components, + IReadOnlyDictionary? Metadata); + + private sealed record DistributionDocument( + string DistributionId, + string Version, + string Channel, + string Platform, + DateTimeOffset PublishedAt, + string? FileMapUrl, + string? FileMapSignatureUrl, + IReadOnlyList Components, + IReadOnlyList InstallerMirrors, + IReadOnlyList Capabilities, + IReadOnlyDictionary? 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 Files, + IReadOnlyDictionary? Metadata); + + private sealed record FileEntryDocument( + string Path, + string Action, + string Sha256, + long Size, + string Mode, + string? ObjectKey, + string? ObjectUrl, + IReadOnlyDictionary? Metadata); + + private sealed record InstallerMirrorDocument( + string Platform, + string Arch, + string? Url, + string? FileName, + string? Sha256, + long Size); +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs new file mode 100644 index 0000000..6992fcc --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublishOptions.cs @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs new file mode 100644 index 0000000..e931b30 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Publishing/PlondsPublisher.cs @@ -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 Publish(PlondsPublishOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var results = new List(); + 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 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 + { + ["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 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 InstallerExtensions, + IReadOnlyList FileNameTokens); +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Security/RsaFileSigner.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Security/RsaFileSigner.cs new file mode 100644 index 0000000..f6543a7 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Core/Security/RsaFileSigner.cs @@ -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; + } +} diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChannelPointer.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChannelPointer.cs new file mode 100644 index 0000000..3a83bb0 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsChannelPointer.cs @@ -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); + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsComponent.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsComponent.cs new file mode 100644 index 0000000..796f04c --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsComponent.cs @@ -0,0 +1,9 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsComponent( + string Id, + string Root, + string Mode, + IReadOnlyList Files, + IReadOnlyDictionary? Metadata = null); + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDistributionInfo.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDistributionInfo.cs new file mode 100644 index 0000000..099c97d --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsDistributionInfo.cs @@ -0,0 +1,14 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsDistributionInfo( + string DistributionId, + string Version, + string Channel, + string Platform, + DateTimeOffset PublishedAt, + IReadOnlyList Components, + IReadOnlyList InstallerMirrors, + IReadOnlyList Capabilities, + IReadOnlyList Signatures, + IReadOnlyDictionary? Metadata = null); + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileEntry.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileEntry.cs new file mode 100644 index 0000000..3b73112 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileEntry.cs @@ -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); + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileMap.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileMap.cs new file mode 100644 index 0000000..d4b885b --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsFileMap.cs @@ -0,0 +1,13 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsFileMap( + string FormatVersion, + string DistributionId, + string SourceVersion, + string TargetVersion, + string Platform, + IReadOnlyList Components, + IReadOnlyList Capabilities, + IReadOnlyList Signatures, + IReadOnlyDictionary? Metadata = null); + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMetadataCatalog.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMetadataCatalog.cs new file mode 100644 index 0000000..12781ba --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMetadataCatalog.cs @@ -0,0 +1,10 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsMetadataCatalog( + string ProtocolName, + string ProtocolVersion, + string StorageRoot, + string MetaRoot, + IReadOnlyList Latest, + IReadOnlyDictionary? Metadata = null); + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorAsset.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorAsset.cs new file mode 100644 index 0000000..6a3ef77 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsMirrorAsset.cs @@ -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); diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSignatureDescriptor.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSignatureDescriptor.cs new file mode 100644 index 0000000..7067f7a --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Models/PlondsSignatureDescriptor.cs @@ -0,0 +1,7 @@ +namespace Plonds.Shared.Models; + +public sealed record PlondsSignatureDescriptor( + string Algorithm, + string KeyId, + string Signature); + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Plonds.Shared.csproj b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Plonds.Shared.csproj new file mode 100644 index 0000000..871e569 --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/Plonds.Shared.csproj @@ -0,0 +1,6 @@ + + + Plonds.Shared + + + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsConstants.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsConstants.cs new file mode 100644 index 0000000..3578c5c --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsConstants.cs @@ -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 + ]; +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsFileOperation.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsFileOperation.cs new file mode 100644 index 0000000..99888cc --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Shared/PlondsFileOperation.cs @@ -0,0 +1,10 @@ +namespace Plonds.Shared; + +public enum PlondsFileOperation +{ + Add, + Replace, + Reuse, + Delete +} + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj new file mode 100644 index 0000000..b9bda3c --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj @@ -0,0 +1,9 @@ + + + Exe + + + + + + diff --git a/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs new file mode 100644 index 0000000..ccdbadf --- /dev/null +++ b/PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Program.cs @@ -0,0 +1,142 @@ +using Plonds.Core.Publishing; +using Plonds.Core.Security; + +return await PlondsCli.RunAsync(args); + +internal static class PlondsCli +{ + public static Task 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 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 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 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 ParseOptions(string[] args) + { + var result = new Dictionary(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 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 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 --current-dir --platform --output-dir [--previous-version ] [--previous-dir ]"); + Console.WriteLine(" sign --manifest --private-key [--output ]"); + Console.WriteLine(" publish --version --app-artifacts-root --installer-artifacts-root --output-dir --private-key [--baseline-root ]"); + } +} diff --git a/scripts/Generate-PlondsArtifacts.ps1 b/scripts/Generate-PlondsArtifacts.ps1 new file mode 100644 index 0000000..0a52979 --- /dev/null +++ b/scripts/Generate-PlondsArtifacts.ps1 @@ -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." +} diff --git a/scripts/Publish-Plonds.ps1 b/scripts/Publish-Plonds.ps1 new file mode 100644 index 0000000..6a63545 --- /dev/null +++ b/scripts/Publish-Plonds.ps1 @@ -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" diff --git a/scripts/Sign-FileMap.ps1 b/scripts/Sign-FileMap.ps1 index fe92155..b266fd7 100644 --- a/scripts/Sign-FileMap.ps1 +++ b/scripts/Sign-FileMap.ps1 @@ -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"