From fb21bcd8ec938efe28d383dd54b56fcc0ba275e3 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 20 Apr 2026 12:55:19 +0800 Subject: [PATCH] refactor update backend to host-managed PDC pipeline --- .github/workflows/release.yml | 392 ++++---- .../pdc-incremental-migration/checklist.md | 13 +- .trae/specs/pdc-incremental-migration/spec.md | 52 +- .../specs/pdc-incremental-migration/tasks.md | 21 +- LanMountainDesktop.Launcher/App.axaml.cs | 2 - LanMountainDesktop.Launcher/AppJsonContext.cs | 5 + .../Models/UpdateModels.cs | 89 ++ .../Services/LauncherFlowCoordinator.cs | 3 - .../Services/UpdateEngineService.cs | 951 +++++++++++++++++- .../Models/AppSettingsSnapshot.cs | 2 +- .../Services/GitHubReleaseUpdateService.cs | 12 +- .../Services/PdcReleaseUpdateService.cs | 122 ++- .../Services/Settings/SettingsContracts.cs | 1 + .../Settings/SettingsDomainServices.cs | 12 + .../Services/UpdateSettingsValues.cs | 14 +- .../Services/UpdateWorkflowService.cs | 514 +++++++++- phainon.yml | 24 +- scripts/Install-Pdcc.ps1 | 101 ++ scripts/Prepare-PdccOut.ps1 | 59 ++ 19 files changed, 2063 insertions(+), 326 deletions(-) create mode 100644 scripts/Install-Pdcc.ps1 create mode 100644 scripts/Prepare-PdccOut.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cd23b8..74b0120 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -317,96 +317,19 @@ jobs: Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB" shell: pwsh - - name: Build Signed FileMap Update Package - if: matrix.self_contained == true - run: | - $ErrorActionPreference = "Stop" - - $version = "${{ needs.prepare.outputs.version }}" - $arch = "${{ matrix.arch }}" - $platform = "windows-$arch" - $publishDir = "publish/windows-$arch" - $appDir = "app-$version" - $currentAppPath = Join-Path $publishDir $appDir - $outputDir = Join-Path "delta-output" $platform - $generateScript = "scripts/Generate-DeltaPackage.ps1" - $signScript = "scripts/Sign-FileMap.ps1" - - if (-not (Test-Path $currentAppPath)) { - Write-Error "Expected app directory not found: $currentAppPath" - exit 1 - } - - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - & $generateScript ` - -PreviousVersion "0.0.0" ` - -CurrentVersion $version ` - -PreviousDir $currentAppPath ` - -CurrentDir $currentAppPath ` - -OutputDir $outputDir - - $privateKeyPem = @' - ${{ secrets.PDC_SIGNING_KEY }} - '@.Trim() - if ([string]::IsNullOrWhiteSpace($privateKeyPem)) { - $privateKeyPem = @' - ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} - '@.Trim() - } - if ([string]::IsNullOrWhiteSpace($privateKeyPem)) { - Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM" - exit 1 - } - - $privateKeyPem = $privateKeyPem -replace '\\n', "`n" - $tempDir = Join-Path $env:RUNNER_TEMP "update-signing" - New-Item -ItemType Directory -Path $tempDir -Force | Out-Null - - $privateKeyPath = Join-Path $tempDir "private-key.pem" - $publicKeyPath = Join-Path $tempDir "public-key.pem" - - Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline - $rsa = [System.Security.Cryptography.RSA]::Create() - $rsa.ImportFromPem($privateKeyPem) - $derivedPublicKey = $rsa.ExportRSAPublicKeyPem() - Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline - - $repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem" - $repoPublicKeyPem = Get-Content -Path $repoPublicKeyPath -Raw - $repoRsa = [System.Security.Cryptography.RSA]::Create() - $repoRsa.ImportFromPem($repoPublicKeyPem) - $repoSpki = [Convert]::ToBase64String($repoRsa.ExportSubjectPublicKeyInfo()) - $derivedSpki = [Convert]::ToBase64String($rsa.ExportSubjectPublicKeyInfo()) - if ($repoSpki -ne $derivedSpki) { - Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing." - exit 1 - } - - & $signScript ` - -FilesJsonPath (Join-Path $outputDir "files.json") ` - -PrivateKeyPath $privateKeyPath ` - -OutputPath (Join-Path $outputDir "files.json.sig") - - Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force - Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force - Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force - shell: pwsh - - - name: Upload Signed FileMap Update Package - if: matrix.self_contained == true + - name: Upload App Payload uses: actions/upload-artifact@v4 with: - name: release-update-windows-${{ matrix.arch }} + name: app-payload-windows-${{ matrix.arch }} path: | - delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json - delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json.sig - delta-output/windows-${{ matrix.arch }}/update-windows-${{ matrix.arch }}.zip + publish/windows-${{ matrix.arch }}/** if-no-files-found: error - retention-days: 90 + retention-days: 30 + - name: Upload Installer uses: actions/upload-artifact@v4 with: - name: release-windows-${{ matrix.arch }}${{ matrix.suffix }} + name: installer-windows-${{ matrix.arch }} path: build-installer/*.exe if-no-files-found: error retention-days: 30 @@ -608,94 +531,19 @@ jobs: exit 1 fi - - name: Build Signed FileMap Update Package - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - - $version = "${{ needs.prepare.outputs.version }}" - $platform = "linux-x64" - $publishDir = "publish/linux-x64" - $appDir = "app-$version" - $currentAppPath = Join-Path $publishDir $appDir - $outputDir = Join-Path "delta-output" $platform - $generateScript = "scripts/Generate-DeltaPackage.ps1" - $signScript = "scripts/Sign-FileMap.ps1" - - if (-not (Test-Path $currentAppPath)) { - Write-Error "Expected app directory not found: $currentAppPath" - exit 1 - } - - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - & $generateScript ` - -PreviousVersion "0.0.0" ` - -CurrentVersion $version ` - -PreviousDir $currentAppPath ` - -CurrentDir $currentAppPath ` - -OutputDir $outputDir - - $privateKeyPem = @' - ${{ secrets.PDC_SIGNING_KEY }} - '@.Trim() - if ([string]::IsNullOrWhiteSpace($privateKeyPem)) { - $privateKeyPem = @' - ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} - '@.Trim() - } - if ([string]::IsNullOrWhiteSpace($privateKeyPem)) { - Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM" - exit 1 - } - - $privateKeyPem = $privateKeyPem -replace '\\n', "`n" - $tempDir = Join-Path $env:RUNNER_TEMP "update-signing" - New-Item -ItemType Directory -Path $tempDir -Force | Out-Null - - $privateKeyPath = Join-Path $tempDir "private-key.pem" - $publicKeyPath = Join-Path $tempDir "public-key.pem" - - Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline - $rsa = [System.Security.Cryptography.RSA]::Create() - $rsa.ImportFromPem($privateKeyPem) - $derivedPublicKey = $rsa.ExportRSAPublicKeyPem() - Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline - - $repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem" - $repoPublicKeyPem = Get-Content -Path $repoPublicKeyPath -Raw - $repoRsa = [System.Security.Cryptography.RSA]::Create() - $repoRsa.ImportFromPem($repoPublicKeyPem) - $repoSpki = [Convert]::ToBase64String($repoRsa.ExportSubjectPublicKeyInfo()) - $derivedSpki = [Convert]::ToBase64String($rsa.ExportSubjectPublicKeyInfo()) - if ($repoSpki -ne $derivedSpki) { - Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing." - exit 1 - } - - & $signScript ` - -FilesJsonPath (Join-Path $outputDir "files.json") ` - -PrivateKeyPath $privateKeyPath ` - -OutputPath (Join-Path $outputDir "files.json.sig") - - Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force - Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force - Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force - - - name: Upload Signed FileMap Update Package + - name: Upload App Payload uses: actions/upload-artifact@v4 with: - name: release-update-linux-x64 + name: app-payload-linux-x64 path: | - delta-output/linux-x64/files-linux-x64.json - delta-output/linux-x64/files-linux-x64.json.sig - delta-output/linux-x64/update-linux-x64.zip + publish/linux-x64/** if-no-files-found: error - retention-days: 90 + retention-days: 30 - - name: Upload + - name: Upload Installer uses: actions/upload-artifact@v4 with: - name: release-linux + name: installer-linux-x64 path: "*.deb" if-no-files-found: error retention-days: 30 @@ -859,23 +707,185 @@ jobs: - name: Upload uses: actions/upload-artifact@v4 with: - name: release-macos-${{ matrix.arch }} + name: installer-macos-${{ matrix.arch }} path: "*.dmg" if-no-files-found: error retention-days: 30 - github-release: + publish-pdc: needs: [ prepare, build-windows, build-linux, build-macos ] 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: ${{ vars.PDC_CLIENT_VERSION }} + PDCC_version: ${{ vars.PDC_CLIENT_VERSION }} + 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 }} + UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + 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_SigningKey: ${{ secrets.PDC_SIGNING_KEY }} + S3_AccessKey: ${{ secrets.S3_ACCESS_KEY }} + S3_SecretKey: ${{ secrets.S3_SECRET_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Download payload artifacts + uses: actions/download-artifact@v4 + with: + path: payload-artifacts + pattern: app-payload-* + + - name: Download installer artifacts + uses: actions/download-artifact@v4 + with: + path: installer-artifacts + pattern: installer-* + + - name: Prepare PDC environment + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + if ([string]::IsNullOrWhiteSpace($env:S3_ENDPOINT) -or + [string]::IsNullOrWhiteSpace($env:S3_BUCKET) -or + [string]::IsNullOrWhiteSpace($env:PDC_ENDPOINT)) { + throw "Missing required PDC/S3 variables." + } + + if ([string]::IsNullOrWhiteSpace($env:PDC_SIGNING_KEY)) { + if ([string]::IsNullOrWhiteSpace($env:UPDATE_PRIVATE_KEY_PEM)) { + throw "Missing UPDATE_PRIVATE_KEY_PEM or PDC_SIGNING_KEY." + } + + $env:PDC_SIGNING_KEY = $env:UPDATE_PRIVATE_KEY_PEM + } + + $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/installers/" + + 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: Install PDCC + shell: pwsh + run: | + ./scripts/Install-Pdcc.ps1 -Repository "ClassIsland/PhainonDistributionCenter" -OutputDir "./pdcc" + + - name: Publish with PDCC + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $stageRoot = Join-Path $PWD "pdc-stage" + $payloadRoot = Join-Path $PWD "payload-artifacts" + $installerRoot = Join-Path $PWD "installer-artifacts" + $outRoot = Join-Path $PWD "pdc-output" + $client = Join-Path $PWD "pdcc/PhainonDistributionCenter.Client" + $config = Join-Path $PWD "pdc-work/phainon.resolved.yml" + + 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 + + $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 + + $subChannel = ($platformKey -replace '-', '_') + "_release_folderClassic" + $env:PDC_SUBCHANNEL = $subChannel + + Push-Location $stagedPayloadDir + try { + & $client $config Publish $env:PRIMARY_VERSION $env:VERSION (Join-Path $outRoot "published/$platformKey") + if ($LASTEXITCODE -ne 0) { + throw "PDCC Publish failed for $platformKey." + } + } + finally { + Pop-Location + } + } + + if (Test-Path (Join-Path $stageRoot "installers")) { + aws --endpoint-url "$env:S3_ENDPOINT" s3 sync (Join-Path $stageRoot "installers") "s3://$env:S3_BUCKET/lanmountain/update/installers/" --only-show-errors + } + + - name: Upload PDC Assets + uses: actions/upload-artifact@v4 + with: + name: pdc-assets + path: | + pdc-output/published/** + if-no-files-found: error + retention-days: 90 + + github-release: + needs: [ prepare, build-windows, build-linux, build-macos, publish-pdc ] + runs-on: ubuntu-latest permissions: contents: write steps: - - name: Download artifacts + - name: Download installer artifacts uses: actions/download-artifact@v4 with: - path: artifacts - pattern: release-* + path: artifacts/installers + pattern: installer-* + + - name: Download PDC artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts/pdc + pattern: pdc-assets - name: List artifacts structure run: | @@ -892,10 +902,8 @@ jobs: run: | echo "Organizing artifacts..." mkdir -p release-files - # Copy installers and packages - find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \; - # Copy signed file-map incremental update assets - find artifacts -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} 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/ \; echo "" echo "Files ready for release:" ls -lh release-files/ || echo "No files found in release-files" @@ -908,44 +916,6 @@ jobs: exit 1 fi - - name: Upload Incremental Assets to S3 (optional) - if: ${{ vars.S3_ENDPOINT != '' && vars.S3_BUCKET != '' }} - env: - S3_ENDPOINT: ${{ vars.S3_ENDPOINT }} - S3_BUCKET: ${{ vars.S3_BUCKET }} - S3_REGION: ${{ vars.S3_REGION != '' && vars.S3_REGION || 'cn-nb1' }} - S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} - S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} - S3_OBJECT_PREFIX: lanmountain/distribution-v1 - run: | - set -euo pipefail - - if [ -z "${S3_ACCESS_KEY:-}" ] || [ -z "${S3_SECRET_KEY:-}" ]; then - echo "S3 credentials are not configured. Skipping optional S3 upload step." - exit 0 - fi - - python3 -m pip install --upgrade awscli - - mkdir -p release-update-assets - find release-files -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-update-assets/ \; - - asset_count=$(find release-update-assets -type f | wc -l) - if [ "$asset_count" -eq 0 ]; then - echo "Error: no incremental update assets found for S3 upload." - exit 1 - fi - - export AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" - export AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" - export AWS_DEFAULT_REGION="$S3_REGION" - - version_prefix="${S3_OBJECT_PREFIX}/${{ needs.prepare.outputs.version }}/" - latest_prefix="${S3_OBJECT_PREFIX}/latest/" - - aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${version_prefix}" --only-show-errors - aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${latest_prefix}" --delete --only-show-errors - - name: Create Release uses: ncipollo/release-action@v1 with: diff --git a/.trae/specs/pdc-incremental-migration/checklist.md b/.trae/specs/pdc-incremental-migration/checklist.md index fabbce1..9212ae9 100644 --- a/.trae/specs/pdc-incremental-migration/checklist.md +++ b/.trae/specs/pdc-incremental-migration/checklist.md @@ -1,10 +1,13 @@ # Checklist -- [x] `release.yml` produces signed FileMap incremental assets for Windows x64/x86 and Linux x64. -- [x] `release.yml` no longer depends on `vpk`/VeloPack packaging. -- [x] Launcher update engine applies only signed FileMap payload path. -- [x] Host update workflow no longer expects `releases.win.json`/`*.nupkg`. -- [x] Update source setting includes `pdc` and preserves GitHub fallback behavior. +- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack. +- [ ] `release.yml` uploads app payload artifacts for PDCC. +- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix). +- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run. +- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized. +- [ ] Host can persist PDC payload into launcher incoming directory. +- [ ] Launcher can apply PDC FileMap payload with signature/hash verification. +- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback. - [ ] CI run attached proving all release matrix jobs pass. - [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64. - [ ] Rollback verification report attached. diff --git a/.trae/specs/pdc-incremental-migration/spec.md b/.trae/specs/pdc-incremental-migration/spec.md index ec308e8..b9dfecb 100644 --- a/.trae/specs/pdc-incremental-migration/spec.md +++ b/.trae/specs/pdc-incremental-migration/spec.md @@ -2,29 +2,43 @@ ## Goal -Replace VeloPack-based incremental packaging with a unified signed FileMap pipeline and prepare for PDC/S3 distribution compatibility, while keeping Launcher installation, rollback, and update orchestration ownership unchanged. +Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged. -## Stage 1 (Completed in this round) +## Stage 1 (Completed) -- Release workflow outputs signed FileMap incremental assets as the primary path: - - `files-windows-x64.json` / `.sig` / `update-windows-x64.zip` - - `files-windows-x86.json` / `.sig` / `update-windows-x86.zip` - - `files-linux-x64.json` / `.sig` / `update-linux-x64.zip` -- Launcher and host update runtime remove VeloPack branches and return to signed FileMap apply path. -- Host update asset discovery supports platform-scoped names with fallback to legacy generic names. -- Optional S3 sync publishes incremental assets in parallel with GitHub Release assets. +- Release workflow removed VeloPack-based release packaging. +- Signed FileMap path was restored as an interim release mechanism. +- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`. -## Stage 2 (In Progress) +## Stage 2 (Current Implementation Target) -- Introduce PDC-compatible update source (`pdc`) with fallback to GitHub. -- Add PDC metadata/latest/distribution API consumption abstraction. -- Keep Launcher install/apply/rollback state machine unchanged. -- Prepare `phainon.yml`-compatible release metadata for future PDCC integration. +- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style). +- Promote PDC-distributed FileMap/object-repo as the primary incremental path. +- Keep GitHub Release installers and metadata as parallel distribution. +- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots). +- Update source defaults to `stcn` (S3/PDC), with GitHub fallback. +- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix. + +Expected S3 layout: + - `lanmountain/update/repo//` + - `lanmountain/update/meta/channels///latest.json` + - `lanmountain/update/meta/distributions//*.json` + - `lanmountain/update/installers///*` ## Acceptance -- `release.yml` no longer contains VeloPack packaging steps. -- Windows x64/x86 and Linux x64 release jobs all upload signed FileMap incremental assets. -- Host auto-update can detect and download platform-matching signed FileMap assets. -- Launcher `update apply` succeeds with signed FileMap payload and rollback behavior remains unchanged. -- Optional S3 upload step works when S3 secrets/vars are configured. +- `release.yml` includes PDCC publish steps and no Velopack steps. +- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS. +- PDC metadata + FileMap + object repo are published under `lanmountain/update/`. +- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable. +- Launcher can apply both: + - legacy signed `files.json + update.zip` + - PDC FileMap object-repo payload. +- Rollback semantics remain unchanged. + +## Deprecated Notes + +- The following interim outputs are compatibility-only (not the long-term primary path): + - `files-windows-x64.json` / `.sig` / `update-windows-x64.zip` + - `files-windows-x86.json` / `.sig` / `update-windows-x86.zip` + - `files-linux-x64.json` / `.sig` / `update-linux-x64.zip` diff --git a/.trae/specs/pdc-incremental-migration/tasks.md b/.trae/specs/pdc-incremental-migration/tasks.md index aebd0db..02998af 100644 --- a/.trae/specs/pdc-incremental-migration/tasks.md +++ b/.trae/specs/pdc-incremental-migration/tasks.md @@ -1,12 +1,15 @@ # Tasks - [x] Remove VeloPack packaging from release workflow. -- [x] Promote signed FileMap generation to release primary path. -- [x] Output platform-scoped incremental assets for Windows x64/x86 and Linux x64. -- [x] Remove launcher/runtime VeloPack branches. -- [x] Update host asset discovery to platform-scoped signed FileMap naming. -- [x] Add optional S3 sync for incremental assets. -- [x] Extend update source values with `pdc`. -- [x] Add PDC check fallback service skeleton in settings domain. -- [ ] Add full PDC FileMap object-hash download/deploy path. -- [ ] Add PDCC publish integration and `phainon.yml` CI publishing flow. +- [x] Keep signed FileMap path as interim compatibility fallback. +- [x] Remove launcher/runtime Velopack branching. +- [ ] Add `phainon.yml` for PDCC publish configuration. +- [ ] Add PDCC installation + publish steps in `release.yml`. +- [ ] Upload app payload artifacts for PDCC consumption in release build jobs. +- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`. +- [ ] Mirror installers to `lanmountain/update/installers///`. +- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility). +- [ ] Add PDC payload model into host update check result. +- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata). +- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics. +- [ ] Keep old `files.json + update.zip` path behind compatibility fallback. diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index f545da1..ce77610 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -214,14 +214,12 @@ public partial class App : Application var deploymentLocator = new DeploymentLocator(appRoot); // TODO: 从配置读取 GitHub 仓库信息 - var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop"); coordinator = new LauncherFlowCoordinator( context, deploymentLocator, new OobeStateService(appRoot), new UpdateEngineService(deploymentLocator), - updateCheckService, new PluginInstallerService()); result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false); diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 7b02ce9..21b81ca 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -9,6 +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(SnapshotMetadata))] [JsonSerializable(typeof(AppVersionInfo))] [JsonSerializable(typeof(StartupProgressMessage))] diff --git a/LanMountainDesktop.Launcher/Models/UpdateModels.cs b/LanMountainDesktop.Launcher/Models/UpdateModels.cs index a27741b..cbabd89 100644 --- a/LanMountainDesktop.Launcher/Models/UpdateModels.cs +++ b/LanMountainDesktop.Launcher/Models/UpdateModels.cs @@ -53,3 +53,92 @@ internal sealed class UpdateApplyResult public string? RolledBackTo { get; init; } } + +internal sealed class PdcUpdateMetadata +{ + public string? DistributionId { get; set; } + + public string? Channel { get; set; } + + public string? SubChannel { get; set; } + + public string? FromVersion { get; set; } + + public string? ToVersion { get; set; } + + public string? FileMapPath { get; set; } + + public string? FileMapSignaturePath { get; set; } + + public Dictionary Metadata { get; set; } = []; +} + +internal sealed class PdcFileMap +{ + public string? DistributionId { get; set; } + + public string? FromVersion { get; set; } + + public string? ToVersion { get; set; } + + public string? Version { get; set; } + + public string? Platform { get; set; } + + public string? Arch { get; set; } + + public Dictionary Metadata { get; set; } = []; + + public List Components { get; set; } = []; + + public List Files { get; set; } = []; +} + +internal sealed class PdcComponentEntry +{ + public string Name { get; set; } = string.Empty; + + public string? Version { get; set; } + + public Dictionary Metadata { get; set; } = []; + + public List Files { get; set; } = []; +} + +internal sealed class PdcFileEntry +{ + public string Path { get; set; } = string.Empty; + + public string? Action { get; set; } = "replace"; + + public string? Url { get; set; } + + public string? ObjectUrl { get; set; } + + public string? ObjectPath { get; set; } + + public string? ObjectKey { get; set; } + + public string? ArchivePath { get; set; } + + public string? Sha256 { get; set; } + + public string? Sha512 { get; set; } + + public string? Sha512Base64 { get; set; } + + public byte[]? Sha512Bytes { get; set; } + + public PdcHashDescriptor? Hash { get; set; } + + public Dictionary Metadata { get; set; } = []; +} + +internal sealed class PdcHashDescriptor +{ + public string? Algorithm { get; set; } + + public string? Value { get; set; } + + public byte[]? Bytes { get; set; } +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index f524c7c..d44bcc8 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -22,7 +22,6 @@ internal sealed class LauncherFlowCoordinator private readonly DeploymentLocator _deploymentLocator; private readonly OobeStateService _oobeStateService; private readonly UpdateEngineService _updateEngine; - private readonly UpdateCheckService _updateCheckService; private readonly PluginInstallerService _pluginInstallerService; private readonly IReadOnlyList _oobeSteps; @@ -31,14 +30,12 @@ internal sealed class LauncherFlowCoordinator DeploymentLocator deploymentLocator, OobeStateService oobeStateService, UpdateEngineService updateEngine, - UpdateCheckService updateCheckService, PluginInstallerService pluginInstallerService) { _context = context; _deploymentLocator = deploymentLocator; _oobeStateService = oobeStateService; _updateEngine = updateEngine; - _updateCheckService = updateCheckService; _pluginInstallerService = pluginInstallerService; _oobeSteps = [new WelcomeOobeStep(_oobeStateService)]; } diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index a637a80..7a7f1c6 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -14,6 +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 PublicKeyFileName = "public-key.pem"; private readonly DeploymentLocator _deploymentLocator; @@ -33,6 +37,36 @@ 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); + if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) + { + var pdcFileMapText = File.ReadAllText(pdcFileMapPath); + var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PdcFileMap); + if (pdcFileMap is null) + { + return Failed("update.check", "invalid_manifest", "pdc-filemap.json is invalid."); + } + + var pdcVerified = VerifySignature(pdcFileMapPath, pdcSignaturePath, PdcSignatureFileName); + if (!pdcVerified.Success) + { + return Failed("update.check", "signature_failed", pdcVerified.Message); + } + + var pdcMetadata = LoadPdcUpdateMetadata(pdcUpdatePath); + return new LauncherResult + { + Success = true, + Stage = "update.check", + Code = "available", + Message = "Pending PDC update is available.", + CurrentVersion = _deploymentLocator.GetCurrentVersion(), + TargetVersion = ResolvePdcTargetVersion(pdcFileMap, pdcMetadata) + }; + } + var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); @@ -54,7 +88,7 @@ internal sealed class UpdateEngineService return Failed("update.check", "invalid_manifest", "files.json is invalid."); } - var verified = VerifySignature(fileMapPath, signaturePath); + var verified = VerifySignature(fileMapPath, signaturePath, SignatureFileName); if (!verified.Success) { return Failed("update.check", "signature_failed", verified.Message); @@ -71,43 +105,20 @@ internal sealed class UpdateEngineService }; } - public async Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken) + public Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken) { - Directory.CreateDirectory(_incomingRoot); - using var client = new HttpClient - { - Timeout = TimeSpan.FromMinutes(2) - }; + _ = manifestUrl; + _ = signatureUrl; + _ = archiveUrl; + _ = cancellationToken; - var manifestPath = Path.Combine(_incomingRoot, SignedFileMapName); - var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); - var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); - - await using (var stream = await client.GetStreamAsync(manifestUrl, cancellationToken).ConfigureAwait(false)) - await using (var output = File.Create(manifestPath)) + return Task.FromResult(new LauncherResult { - await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); - } - - await using (var stream = await client.GetStreamAsync(signatureUrl, cancellationToken).ConfigureAwait(false)) - await using (var output = File.Create(signaturePath)) - { - await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); - } - - await using (var stream = await client.GetStreamAsync(archiveUrl, cancellationToken).ConfigureAwait(false)) - await using (var output = File.Create(archivePath)) - { - await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); - } - - return new LauncherResult - { - Success = true, + Success = false, Stage = "update.download", - Code = "ok", - Message = "Update downloaded." - }; + Code = "host_managed_only", + Message = "Launcher no longer performs network downloads. Host must download update payload into incoming directory first." + }); } public async Task ApplyPendingUpdateAsync() @@ -115,6 +126,14 @@ 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); + if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) + { + return await ApplyPendingPdcUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath); + } + var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); @@ -130,7 +149,7 @@ internal sealed class UpdateEngineService }; } - var verifyResult = VerifySignature(fileMapPath, signaturePath); + var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName); if (!verifyResult.Success) { return Failed("update.apply", "signature_failed", verifyResult.Message); @@ -261,6 +280,832 @@ internal sealed class UpdateEngineService } } + private async Task ApplyPendingPdcUpdateAsync( + string pdcFileMapPath, + string pdcSignaturePath, + string pdcUpdatePath) + { + var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PdcSignatureFileName); + 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); + if (fileEntries.Count == 0) + { + PopulatePdcManifestFromRawJson(fileMapText, fileMap, fileEntries); + } + + if (fileEntries.Count == 0) + { + return Failed("update.apply", "invalid_manifest", "No PDC file entries were found."); + } + + var pdcMetadata = LoadPdcUpdateMetadata(pdcUpdatePath); + + var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); + var currentVersion = _deploymentLocator.GetCurrentVersion(); + var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion; + var expectedSourceVersion = ResolvePdcSourceVersion(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}."); + } + + var targetVersion = ResolvePdcTargetVersion(fileMap, pdcMetadata); + if (string.IsNullOrWhiteSpace(targetVersion)) + { + targetVersion = sourceVersion; + } + + var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment); + var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion!); + var partialMarker = Path.Combine(targetDeployment, ".partial"); + var snapshot = new SnapshotMetadata + { + SnapshotId = Guid.NewGuid().ToString("N"), + SourceVersion = sourceVersion, + TargetVersion = targetVersion, + CreatedAt = DateTimeOffset.UtcNow, + SourceDirectory = currentDeployment ?? string.Empty, + TargetDirectory = targetDeployment, + Status = "pending" + }; + var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json"); + + try + { + SaveSnapshot(snapshotPath, snapshot); + + if (Directory.Exists(targetDeployment)) + { + Directory.Delete(targetDeployment, true); + } + + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(partialMarker, string.Empty); + + foreach (var entry in fileEntries) + { + ApplyPdcFileEntry(entry, currentDeployment, targetDeployment); + } + + foreach (var entry in fileEntries) + { + VerifyPdcFileEntry(entry, targetDeployment); + } + + if (isInitialDeployment) + { + File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty); + if (File.Exists(partialMarker)) + { + File.Delete(partialMarker); + } + } + else + { + ActivateDeployment(currentDeployment!, targetDeployment); + } + + snapshot.Status = "applied"; + SaveSnapshot(snapshotPath, snapshot); + CleanupIncomingArtifacts(); + CleanupDestroyedDeployments(); + + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "ok", + Message = $"Updated to {targetVersion}.", + CurrentVersion = sourceVersion, + TargetVersion = targetVersion + }; + } + catch (Exception ex) + { + if (isInitialDeployment) + { + try + { + if (Directory.Exists(targetDeployment)) + { + Directory.Delete(targetDeployment, true); + } + } + catch + { + } + + snapshot.Status = "failed"; + SaveSnapshot(snapshotPath, snapshot); + return new LauncherResult + { + Success = false, + Stage = "update.apply", + Code = "initial_deploy_failed", + Message = "Failed to apply initial PDC deployment.", + ErrorMessage = ex.Message, + CurrentVersion = "0.0.0", + TargetVersion = targetVersion + }; + } + + TryRollbackOnFailure(snapshot); + snapshot.Status = "rolled_back"; + SaveSnapshot(snapshotPath, snapshot); + return new LauncherResult + { + Success = false, + Stage = "update.apply", + Code = "apply_failed", + Message = "Failed to apply PDC update. Rolled back to previous version.", + ErrorMessage = ex.Message, + CurrentVersion = sourceVersion, + RolledBackTo = sourceVersion + }; + } + } + + private void ApplyPdcFileEntry(PdcFileEntry file, string? currentDeployment, string targetDeployment) + { + var normalizedPath = NormalizeRelativePath(file.Path); + var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; + if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var targetPath = Path.Combine(targetDeployment, normalizedPath); + EnsurePathWithinRoot(targetPath, targetDeployment); + var targetDir = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(targetDir)) + { + Directory.CreateDirectory(targetDir); + } + + if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(currentDeployment)) + { + throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available."); + } + + var sourcePath = Path.Combine(currentDeployment, normalizedPath); + EnsurePathWithinRoot(sourcePath, currentDeployment); + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment."); + } + + File.Copy(sourcePath, targetPath, overwrite: true); + return; + } + + var objectPath = ResolvePdcObjectPath(file); + var objectBytes = File.ReadAllBytes(objectPath); + var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes; + File.WriteAllBytes(targetPath, restoredBytes); + } + + private void VerifyPdcFileEntry(PdcFileEntry file, string targetDeployment) + { + var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; + if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var targetPath = Path.Combine(targetDeployment, NormalizeRelativePath(file.Path)); + EnsurePathWithinRoot(targetPath, targetDeployment); + if (!File.Exists(targetPath)) + { + throw new FileNotFoundException($"Expected target file was not created: {file.Path}"); + } + + if (TryGetExpectedSha512(file, out var expectedSha512)) + { + var actualSha512 = ComputeSha512(targetPath); + if (!actualSha512.AsSpan().SequenceEqual(expectedSha512)) + { + throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'."); + } + return; + } + + if (!string.IsNullOrWhiteSpace(file.Sha256)) + { + var expectedSha256 = NormalizeHashText(file.Sha256); + var actualSha256 = ComputeSha256Hex(targetPath); + if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'."); + } + } + } + + private string ResolvePdcObjectPath(PdcFileEntry 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); + + if (TryGetExpectedObjectSha512(file, out var expectedSha512) || TryGetExpectedSha512(file, out expectedSha512)) + { + var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant(); + AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex)); + if (hashHex.Length > 2) + { + AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex[..2], hashHex)); + // Backward compatibility for previously staged paths. + AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, hashHex[..2], hashHex[2..])); + } + AddPdcPathCandidates(candidates, Path.Combine(PdcObjectsDirectoryName, $"{hashHex}.gz")); + } + + foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var fullPath = Path.GetFullPath(Path.Combine(_incomingRoot, relativePath)); + if (!fullPath.StartsWith(Path.GetFullPath(_incomingRoot), StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (File.Exists(fullPath)) + { + return fullPath; + } + } + + throw new FileNotFoundException($"Unable to resolve object payload for '{file.Path}'."); + } + + private static byte[]? TryInflateGzip(byte[] payload) + { + try + { + using var input = new MemoryStream(payload, writable: false); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } + catch + { + return null; + } + } + + private void AddPdcPathCandidates(ICollection candidates, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + var normalized = value.Trim(); + if (Uri.TryCreate(normalized, UriKind.Absolute, out var absoluteUri)) + { + normalized = Uri.UnescapeDataString(absoluteUri.AbsolutePath); + } + + normalized = normalized.TrimStart('/', '\\'); + if (string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + candidates.Add(normalized); + + if (!normalized.StartsWith($"{PdcObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + { + candidates.Add(Path.Combine(PdcObjectsDirectoryName, normalized)); + } + + var fileName = Path.GetFileName(normalized); + if (!string.IsNullOrWhiteSpace(fileName)) + { + candidates.Add(Path.Combine(PdcObjectsDirectoryName, fileName)); + } + } + + private static bool TryGetExpectedSha512(PdcFileEntry file, out byte[] expected) + { + expected = []; + if (file.Sha512Bytes is { Length: > 0 }) + { + expected = file.Sha512Bytes; + return true; + } + + if (file.Hash is not null) + { + if (file.Hash.Bytes is { Length: > 0 }) + { + expected = file.Hash.Bytes; + return true; + } + + if (string.IsNullOrWhiteSpace(file.Hash.Algorithm) || + file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) + { + if (TryParseHashBytes(file.Hash.Value, out expected)) + { + return true; + } + } + } + + if (TryParseHashBytes(file.Sha512, out expected)) + { + return true; + } + + return TryParseHashBytes(file.Sha512Base64, out expected); + } + + private static bool TryGetExpectedObjectSha512(PdcFileEntry file, out byte[] expected) + { + expected = []; + if (file.Hash is null) + { + return false; + } + + if (file.Hash.Bytes is { Length: > 0 }) + { + expected = file.Hash.Bytes; + return true; + } + + if (!string.IsNullOrWhiteSpace(file.Hash.Algorithm) && + !file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return TryParseHashBytes(file.Hash.Value, out expected); + } + + private static bool TryParseHashBytes(string? rawHash, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrWhiteSpace(rawHash)) + { + return false; + } + + var normalized = rawHash.Trim(); + var separator = normalized.IndexOf(':'); + if (separator >= 0 && separator < normalized.Length - 1) + { + normalized = normalized[(separator + 1)..].Trim(); + } + + var compact = normalized.Replace("-", string.Empty); + if (compact.Length > 0 && compact.Length % 2 == 0 && IsHexString(compact)) + { + try + { + bytes = Convert.FromHexString(compact); + return true; + } + catch + { + return false; + } + } + + try + { + bytes = Convert.FromBase64String(normalized); + return bytes.Length > 0; + } + catch + { + return false; + } + } + + private static bool IsHexString(string value) + { + foreach (var ch in value) + { + if (!Uri.IsHexDigit(ch)) + { + return false; + } + } + + return true; + } + + 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 List CollectPdcFileEntries(PdcFileMap fileMap) + { + var files = new List(); + if (fileMap.Files is { Count: > 0 }) + { + files.AddRange(fileMap.Files); + } + + if (fileMap.Components is null) + { + return files; + } + + foreach (var component in fileMap.Components) + { + if (component.Files is { Count: > 0 }) + { + files.AddRange(component.Files); + } + } + + return files; + } + + private static void PopulatePdcManifestFromRawJson(string fileMapJson, PdcFileMap fileMap, ICollection files) + { + if (string.IsNullOrWhiteSpace(fileMapJson)) + { + return; + } + + using var document = JsonDocument.Parse(fileMapJson); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return; + } + + fileMap.FromVersion ??= ReadJsonStringIgnoreCase(root, "fromversion"); + fileMap.ToVersion ??= ReadJsonStringIgnoreCase(root, "toversion"); + fileMap.Version ??= ReadJsonStringIgnoreCase(root, "version"); + fileMap.Platform ??= ReadJsonStringIgnoreCase(root, "platform"); + fileMap.Arch ??= ReadJsonStringIgnoreCase(root, "arch"); + fileMap.DistributionId ??= ReadJsonStringIgnoreCase(root, "distributionid"); + + if (TryGetJsonPropertyIgnoreCase(root, "metadata", out var metadataNode) && + metadataNode.ValueKind == JsonValueKind.Object) + { + foreach (var property in metadataNode.EnumerateObject()) + { + var key = property.Name; + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + var value = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : property.Value.ToString(); + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + fileMap.Metadata[key] = value; + } + } + + if (TryGetJsonPropertyIgnoreCase(root, "files", out var rootFilesNode)) + { + ParsePdcFilesNode(rootFilesNode, null, files); + } + + if (!TryGetJsonPropertyIgnoreCase(root, "components", out var componentsNode)) + { + return; + } + + if (componentsNode.ValueKind == JsonValueKind.Object) + { + foreach (var component in componentsNode.EnumerateObject()) + { + if (component.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (TryGetJsonPropertyIgnoreCase(component.Value, "files", out var componentFilesNode)) + { + ParsePdcFilesNode(componentFilesNode, component.Name, files); + } + } + + return; + } + + if (componentsNode.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var component in componentsNode.EnumerateArray()) + { + if (component.ValueKind != JsonValueKind.Object) + { + continue; + } + + var componentName = ReadJsonStringIgnoreCase(component, "name"); + if (TryGetJsonPropertyIgnoreCase(component, "files", out var componentFilesNode)) + { + ParsePdcFilesNode(componentFilesNode, componentName, files); + } + } + } + + private static void ParsePdcFilesNode(JsonElement filesNode, string? componentName, ICollection files) + { + if (filesNode.ValueKind == JsonValueKind.Object) + { + foreach (var fileEntry in filesNode.EnumerateObject()) + { + if (fileEntry.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (TryCreatePdcFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed)) + { + files.Add(parsed); + } + } + + return; + } + + if (filesNode.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var fileEntry in filesNode.EnumerateArray()) + { + if (fileEntry.ValueKind != JsonValueKind.Object) + { + continue; + } + + var fallbackPath = ReadJsonStringIgnoreCase(fileEntry, "path"); + if (TryCreatePdcFileEntry(fallbackPath, componentName, fileEntry, out var parsed)) + { + files.Add(parsed); + } + } + } + + private static bool TryCreatePdcFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PdcFileEntry entry) + { + entry = new PdcFileEntry(); + var path = ReadJsonStringIgnoreCase(node, "path"); + if (string.IsNullOrWhiteSpace(path)) + { + path = fallbackPath; + } + + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var fileSha512 = ReadJsonByteArrayIgnoreCase(node, "filesha512") + ?? ReadJsonByteArrayIgnoreCase(node, "sha512"); + var archiveSha512 = ReadJsonByteArrayIgnoreCase(node, "archivesha512"); + + var fileSha512Text = ReadJsonStringIgnoreCase(node, "filesha512") + ?? ReadJsonStringIgnoreCase(node, "sha512"); + var archiveSha512Text = ReadJsonStringIgnoreCase(node, "archivesha512"); + + var downloadUrl = ReadJsonStringIgnoreCase(node, "archivedownloadurl") + ?? ReadJsonStringIgnoreCase(node, "downloadurl") + ?? ReadJsonStringIgnoreCase(node, "url"); + var objectPath = ReadJsonStringIgnoreCase(node, "objectpath") + ?? ReadJsonStringIgnoreCase(node, "archivepath"); + var objectKey = ReadJsonStringIgnoreCase(node, "objectkey"); + var action = ReadJsonStringIgnoreCase(node, "action"); + + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(componentName)) + { + metadata["component"] = componentName; + } + + entry = new PdcFileEntry + { + Path = path, + Action = string.IsNullOrWhiteSpace(action) ? "replace" : action, + Url = downloadUrl, + ObjectUrl = ReadJsonStringIgnoreCase(node, "objecturl"), + ObjectPath = objectPath, + ObjectKey = objectKey, + ArchivePath = ReadJsonStringIgnoreCase(node, "archivepath"), + Sha256 = ReadJsonStringIgnoreCase(node, "sha256") ?? ReadJsonStringIgnoreCase(node, "filesha256"), + Sha512 = fileSha512Text, + Sha512Base64 = null, + Sha512Bytes = fileSha512, + Metadata = metadata + }; + + if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text)) + { + entry.Hash = new PdcHashDescriptor + { + Algorithm = "sha512", + Bytes = archiveSha512, + Value = archiveSha512Text ?? (archiveSha512 is { Length: > 0 } + ? Convert.ToHexString(archiveSha512).ToLowerInvariant() + : null) + }; + } + else if (TryGetJsonPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object) + { + entry.Hash = new PdcHashDescriptor + { + Algorithm = ReadJsonStringIgnoreCase(hashNode, "algorithm"), + Value = ReadJsonStringIgnoreCase(hashNode, "value"), + Bytes = ReadJsonByteArrayIgnoreCase(hashNode, "bytes") + }; + } + + return true; + } + + private static bool TryGetJsonPropertyIgnoreCase(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 string? ReadJsonStringIgnoreCase(JsonElement node, string propertyName) + { + if (!TryGetJsonPropertyIgnoreCase(node, propertyName, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString() + : value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? null + : value.ToString(); + } + + private static byte[]? ReadJsonByteArrayIgnoreCase(JsonElement node, string propertyName) + { + if (!TryGetJsonPropertyIgnoreCase(node, propertyName, out var value)) + { + return null; + } + + return ParseJsonByteArrayValue(value); + } + + private static byte[]? ParseJsonByteArrayValue(JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.Array: + { + var bytes = new byte[value.GetArrayLength()]; + var index = 0; + foreach (var element in value.EnumerateArray()) + { + if (!element.TryGetInt32(out var number) || number < byte.MinValue || number > byte.MaxValue) + { + return null; + } + + bytes[index++] = (byte)number; + } + + return bytes; + } + case JsonValueKind.String: + { + var text = value.GetString(); + return TryParseHashBytes(text, out var parsed) ? parsed : null; + } + default: + return null; + } + } + + private static PdcUpdateMetadata? LoadPdcUpdateMetadata(string path) + { + if (!File.Exists(path)) + { + return null; + } + + try + { + var text = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return JsonSerializer.Deserialize(text, AppJsonContext.Default.PdcUpdateMetadata); + } + catch + { + return null; + } + } + + private static string? ResolvePdcSourceVersion(PdcFileMap fileMap, PdcUpdateMetadata? metadata) + { + return FirstNonEmpty( + metadata?.FromVersion, + fileMap.FromVersion, + TryGetMetadataValue(fileMap.Metadata, "fromVersion"), + TryGetMetadataValue(fileMap.Metadata, "sourceVersion")); + } + + private static string? ResolvePdcTargetVersion(PdcFileMap fileMap, PdcUpdateMetadata? metadata) + { + return FirstNonEmpty( + metadata?.ToVersion, + fileMap.ToVersion, + fileMap.Version, + TryGetMetadataValue(fileMap.Metadata, "toVersion"), + TryGetMetadataValue(fileMap.Metadata, "targetVersion")); + } + + private static string? TryGetMetadataValue(Dictionary? metadata, string key) + { + if (metadata is null || metadata.Count == 0) + { + return null; + } + + foreach (var pair in metadata) + { + if (!string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(pair.Value)) + { + return pair.Value; + } + } + + return null; + } + + private static string? FirstNonEmpty(params string?[] values) + { + foreach (var value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + /// /// 全新安装场景:直接应用更新包作为首次部署 /// @@ -573,7 +1418,10 @@ internal sealed class UpdateEngineService { Path.Combine(_incomingRoot, SignedFileMapName), Path.Combine(_incomingRoot, SignatureFileName), - Path.Combine(_incomingRoot, ArchiveFileName) + Path.Combine(_incomingRoot, ArchiveFileName), + Path.Combine(_incomingRoot, PdcFileMapName), + Path.Combine(_incomingRoot, PdcSignatureFileName), + Path.Combine(_incomingRoot, PdcUpdateMetadataName) }) { try @@ -587,13 +1435,30 @@ internal sealed class UpdateEngineService { } } + + foreach (var directory in new[] + { + Path.Combine(_incomingRoot, PdcObjectsDirectoryName) + }) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + catch + { + } + } } - private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath) + private (bool Success, string Message) VerifySignature(string payloadPath, string signaturePath, string signatureName) { if (!File.Exists(signaturePath)) { - return (false, "Missing files.json.sig."); + return (false, $"Missing {signatureName}."); } var publicKeyPath = Path.Combine(_launcherRoot, UpdateDirectoryName, PublicKeyFileName); @@ -602,7 +1467,7 @@ internal sealed class UpdateEngineService return (false, $"Missing public key: {publicKeyPath}"); } - var jsonBytes = File.ReadAllBytes(fileMapPath); + var payloadBytes = File.ReadAllBytes(payloadPath); var signatureBase64 = File.ReadAllText(signaturePath).Trim(); if (string.IsNullOrWhiteSpace(signatureBase64)) { @@ -621,7 +1486,7 @@ internal sealed class UpdateEngineService using var rsa = RSA.Create(); rsa.ImportFromPem(File.ReadAllText(publicKeyPath)); - var isValid = rsa.VerifyData(jsonBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var isValid = rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return isValid ? (true, "ok") : (false, "Signature verification failed."); } @@ -654,6 +1519,12 @@ internal sealed class UpdateEngineService return Convert.ToHexString(hash).ToLowerInvariant(); } + private static byte[] ComputeSha512(string filePath) + { + using var stream = File.OpenRead(filePath); + return SHA512.HashData(stream); + } + private static void SaveSnapshot(string path, SnapshotMetadata snapshot) { File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 8902aab..f83d756 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot public string UpdateMode { get; set; } = "download_then_confirm"; - public string UpdateDownloadSource { get; set; } = "pdc"; + public string UpdateDownloadSource { get; set; } = "stcn"; public int UpdateDownloadThreads { get; set; } = 4; diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs index 0832e45..61ac365 100644 --- a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -34,7 +34,17 @@ public sealed record UpdateCheckResult( GitHubReleaseInfo? Release, GitHubReleaseAsset? PreferredAsset, string? ErrorMessage, - bool ForceMode = false); + bool ForceMode = false, + PdcUpdatePayload? PdcPayload = null); + +public sealed record PdcUpdatePayload( + string DistributionId, + string ChannelId, + string SubChannel, + string? FileMapJson, + string? FileMapSignature, + string? FileMapJsonUrl, + string? FileMapSignatureUrl); public sealed record UpdateDownloadResult( bool Success, diff --git a/LanMountainDesktop/Services/PdcReleaseUpdateService.cs b/LanMountainDesktop/Services/PdcReleaseUpdateService.cs index 571053e..c773a0f 100644 --- a/LanMountainDesktop/Services/PdcReleaseUpdateService.cs +++ b/LanMountainDesktop/Services/PdcReleaseUpdateService.cs @@ -148,7 +148,8 @@ public sealed class PdcReleaseUpdateService : IDisposable var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false); var assets = ResolveAssets(distributionNode); - if (assets.Count == 0) + var pdcPayload = ResolvePdcPayload(distributionNode, distributionId, channelId, subChannel); + if (assets.Count == 0 && !HasPdcPayload(pdcPayload)) { return new UpdateCheckResult( Success: false, @@ -168,6 +169,7 @@ public sealed class PdcReleaseUpdateService : IDisposable IsDraft: false, PublishedAt: DateTimeOffset.UtcNow, Assets: assets); + var preferredAsset = SelectPreferredInstallerAsset(assets); return new UpdateCheckResult( Success: true, @@ -175,9 +177,10 @@ public sealed class PdcReleaseUpdateService : IDisposable CurrentVersionText: normalizedCurrentVersionText, LatestVersionText: latestVersionText, Release: release, - PreferredAsset: null, + PreferredAsset: preferredAsset, ErrorMessage: null, - ForceMode: isForce); + ForceMode: isForce, + PdcPayload: pdcPayload); } catch (OperationCanceledException) { @@ -289,6 +292,119 @@ public sealed class PdcReleaseUpdateService : IDisposable return assets; } + private static PdcUpdatePayload ResolvePdcPayload( + JsonElement distributionNode, + string distributionId, + string channelId, + string subChannel) + { + var fileMapJson = ReadString(distributionNode, "fileMapJson"); + var fileMapSignature = ReadString(distributionNode, "fileMapSignature"); + var fileMapJsonUrl = ReadString(distributionNode, "fileMapJsonUrl") + ?? ReadString(distributionNode, "fileMapUrl") + ?? ReadString(distributionNode, "manifestUrl"); + var fileMapSignatureUrl = ReadString(distributionNode, "fileMapSignatureUrl") + ?? ReadString(distributionNode, "signatureUrl"); + return new PdcUpdatePayload( + DistributionId: distributionId, + ChannelId: channelId, + SubChannel: subChannel, + FileMapJson: fileMapJson, + FileMapSignature: fileMapSignature, + FileMapJsonUrl: fileMapJsonUrl, + FileMapSignatureUrl: fileMapSignatureUrl); + } + + private static bool HasPdcPayload(PdcUpdatePayload payload) + { + return !string.IsNullOrWhiteSpace(payload.FileMapJson) + || !string.IsNullOrWhiteSpace(payload.FileMapJsonUrl); + } + + private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList assets) + { + if (assets is null || assets.Count == 0) + { + return null; + } + + if (OperatingSystem.IsWindows()) + { + var archToken = RuntimeInformation.OSArchitecture switch + { + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + _ => "x64" + }; + return assets + .Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".exe", ".msi", archToken))) + .OrderByDescending(x => x.Score) + .FirstOrDefault(x => x.Score > 0) + .Asset; + } + + if (OperatingSystem.IsLinux()) + { + return assets + .Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".deb", ".rpm", "x64"))) + .OrderByDescending(x => x.Score) + .FirstOrDefault(x => x.Score > 0) + .Asset; + } + + if (OperatingSystem.IsMacOS()) + { + var archToken = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + return assets + .Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".dmg", ".pkg", archToken))) + .OrderByDescending(x => x.Score) + .FirstOrDefault(x => x.Score > 0) + .Asset; + } + + return null; + } + + private static int ScoreInstallerAsset(string name, string ext1, string ext2, string archToken) + { + if (string.IsNullOrWhiteSpace(name)) + { + return 0; + } + + var score = 0; + if (name.EndsWith(ext1, StringComparison.OrdinalIgnoreCase)) + { + score += 200; + } + else if (name.EndsWith(ext2, StringComparison.OrdinalIgnoreCase)) + { + score += 160; + } + else + { + return 0; + } + + if (name.Contains("setup", StringComparison.OrdinalIgnoreCase) || + name.Contains("installer", StringComparison.OrdinalIgnoreCase)) + { + score += 50; + } + + if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase)) + { + score += 40; + } + + if (name.Contains("portable", StringComparison.OrdinalIgnoreCase)) + { + score -= 30; + } + + return score; + } + private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease) { if (metadataNode.ValueKind != JsonValueKind.Object || diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 62420dc..09d0d78 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -356,6 +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 DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 35f2784..afa8b18 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -842,6 +842,18 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); } + public async Task GetPdcUpdatePayloadAsync( + 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; + } + public Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, diff --git a/LanMountainDesktop/Services/UpdateSettingsValues.cs b/LanMountainDesktop/Services/UpdateSettingsValues.cs index 0a5af37..3e17837 100644 --- a/LanMountainDesktop/Services/UpdateSettingsValues.cs +++ b/LanMountainDesktop/Services/UpdateSettingsValues.cs @@ -11,7 +11,10 @@ public static class UpdateSettingsValues public const string ModeDownloadThenConfirm = "download_then_confirm"; public const string ModeSilentOnExit = "silent_on_exit"; - public const string DownloadSourcePdc = "pdc"; + // 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 DownloadSourceGitHub = "github"; public const string DownloadSourceGhProxy = "gh-proxy"; @@ -52,6 +55,11 @@ public static class UpdateSettingsValues public static string NormalizeDownloadSource(string? value) { + if (string.Equals(value, LegacyDownloadSourcePdc, StringComparison.OrdinalIgnoreCase)) + { + return DownloadSourceStcn; + } + if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase)) { return DownloadSourcePdc; @@ -67,8 +75,8 @@ public static class UpdateSettingsValues return DownloadSourceGitHub; } - // Default to PDC. Runtime will fallback to GitHub if PDC is unavailable. - return DownloadSourcePdc; + // Default to STCN(PDC/S3). Runtime will fallback to GitHub if STCN is unavailable. + return DownloadSourceStcn; } public static int NormalizeDownloadThreads(int value) diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index 42b2154..7de3ad9 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -5,7 +5,11 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Net.Http; using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.PluginSdk; @@ -53,9 +57,20 @@ public sealed class UpdateWorkflowService private const string LauncherDirectoryName = ".launcher"; private const string UpdateDirectoryName = "update"; private const string IncomingDirectoryName = "incoming"; + private const string IncomingObjectsDirectoryName = "objects"; 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 static readonly HttpClient PdcHttpClient = new() + { + Timeout = TimeSpan.FromMinutes(5) + }; + + private static readonly ResumableDownloadService PdcDownloadService = new(PdcHttpClient); public UpdateWorkflowService(ISettingsFacadeService settingsFacade) { @@ -81,6 +96,11 @@ public sealed class UpdateWorkflowService return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName); } + public static string GetLauncherIncomingObjectsDirectory() + { + return Path.Combine(GetLauncherIncomingDirectory(), IncomingObjectsDirectoryName); + } + /// /// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates. /// @@ -94,6 +114,16 @@ public sealed class UpdateWorkflowService return TryResolveDeltaAssets(release.Assets, out _, out _, out _); } + public static bool IsDeltaUpdateAvailable(UpdateCheckResult checkResult) + { + if (checkResult.PdcPayload is not null) + { + return true; + } + + return checkResult.Release is not null && IsDeltaUpdateAvailable(checkResult.Release); + } + /// /// Downloads signed file-map assets to the Launcher's incoming directory. /// @@ -104,12 +134,24 @@ public sealed class UpdateWorkflowService { ArgumentNullException.ThrowIfNull(checkResult); - if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null) + if (!checkResult.Success || !checkResult.IsUpdateAvailable) { return new UpdateDownloadResult(false, null, "No update available for delta download."); } - if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset)) + if (checkResult.PdcPayload 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) + { + return await DownloadPdcDeltaUpdateAsync(checkResult, progress, cancellationToken); + } + + var release = checkResult.Release; + if (release is null || + !TryResolveDeltaAssets(release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset)) { return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets."); } @@ -189,9 +231,9 @@ public sealed class UpdateWorkflowService { PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName), PendingUpdateVersion = checkResult.LatestVersionText, - PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue - ? null - : checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(), + PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue + ? publishedAt.ToUnixTimeMilliseconds() + : null, LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PendingUpdateSha256 = null }); @@ -201,6 +243,163 @@ public sealed class UpdateWorkflowService return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null); } + private async Task DownloadPdcDeltaUpdateAsync( + UpdateCheckResult checkResult, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var payload = checkResult.PdcPayload; + if (payload is null) + { + return new UpdateDownloadResult(false, null, "PDC payload is missing."); + } + + var incomingDir = GetLauncherIncomingDirectory(); + var objectsDir = GetLauncherIncomingObjectsDirectory(); + + try + { + Directory.CreateDirectory(incomingDir); + Directory.CreateDirectory(objectsDir); + } + catch (Exception ex) + { + return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}"); + } + + try + { + 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 fileMapJson = await EnsurePdcTextResourceAsync( + payload.FileMapJson, + payload.FileMapJsonUrl, + fileMapPath, + cancellationToken); + + var fileMapSignature = await EnsurePdcTextResourceAsync( + payload.FileMapSignature, + payload.FileMapSignatureUrl, + signaturePath, + cancellationToken); + + var downloadEntries = ParsePdcDownloadEntries(fileMapJson); + if (downloadEntries.Count == 0) + { + return new UpdateDownloadResult(false, null, "PDC 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 objectTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + var totalSteps = expectedObjectCount + 2; + + foreach (var entry in downloadEntries) + { + if (!objectTargets.Add(entry.ObjectHashHex)) + { + completedItems++; + progress?.Report((double)completedItems / totalSteps); + continue; + } + + var destinationPath = GetPdcObjectDestinationPath(objectsDir, entry.ObjectHashHex); + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + if (File.Exists(destinationPath)) + { + var existingHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken); + if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase)) + { + objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath)); + completedItems++; + progress?.Report((double)completedItems / totalSteps); + continue; + } + } + + var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads); + var downloadResult = await PdcDownloadService.DownloadAsync( + entry.DownloadUrl, + destinationPath, + downloadOptions, + null, + cancellationToken); + + if (!downloadResult.Success) + { + return new UpdateDownloadResult(false, null, $"Failed to download PDC object {entry.RelativePath}: {downloadResult.ErrorMessage}"); + } + + var actualHash = await ComputeFileSha512HexAsync(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}"); + } + + objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath)); + completedItems++; + progress?.Report((double)completedItems / totalSteps); + } + + var updateState = new PdcUpdateState( + checkResult.LatestVersionText, + payload.DistributionId, + payload.ChannelId, + payload.SubChannel, + fileMapPath, + signaturePath, + objectsDir, + DateTimeOffset.UtcNow, + fileMapJson, + fileMapSignature, + objectResults); + + await File.WriteAllTextAsync(updateStatePath, JsonSerializer.Serialize(updateState, UpdateJsonOptions), cancellationToken); + + SaveState(state with + { + PendingUpdateInstallerPath = updateStatePath, + PendingUpdateVersion = checkResult.LatestVersionText, + PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue + ? publishedAt.ToUnixTimeMilliseconds() + : null, + LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + PendingUpdateSha256 = null + }); + + progress?.Report(1d); + AppLogger.Info("UpdateWorkflow", $"PDC update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup."); + return new UpdateDownloadResult(true, updateStatePath, null); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + AppLogger.Warn("UpdateWorkflow", "Failed to download PDC incremental payload.", ex); + return new UpdateDownloadResult(false, null, ex.Message); + } + } + + private static readonly JsonSerializerOptions UpdateJsonOptions = new() + { + WriteIndented = true + }; + /// /// Checks whether the pending update is managed by Launcher incoming payload. /// @@ -213,11 +412,261 @@ public sealed class UpdateWorkflowService return false; } - // Incoming payload updates are identified by files.json or incoming directory path. + // 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.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase); } + private static string GetPdcObjectDestinationPath(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( + string? inlineContent, + string? sourceUrl, + string destinationPath, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(inlineContent)) + { + await File.WriteAllTextAsync(destinationPath, inlineContent, cancellationToken); + return inlineContent; + } + + if (string.IsNullOrWhiteSpace(sourceUrl)) + { + throw new InvalidOperationException("PDC payload does not contain a file map source."); + } + + var downloadResult = await PdcDownloadService.DownloadAsync( + sourceUrl, + destinationPath, + cancellationToken: cancellationToken); + + if (!downloadResult.Success) + { + throw new InvalidOperationException($"Failed to download PDC file map resource: {downloadResult.ErrorMessage}"); + } + + return await File.ReadAllTextAsync(destinationPath, cancellationToken); + } + + private static IReadOnlyList ParsePdcDownloadEntries(string fileMapJson) + { + var entries = new List(); + if (string.IsNullOrWhiteSpace(fileMapJson)) + { + return entries; + } + + using var document = JsonDocument.Parse(fileMapJson); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return entries; + } + + if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode) || + componentsNode.ValueKind != JsonValueKind.Object) + { + return entries; + } + + foreach (var component in componentsNode.EnumerateObject()) + { + if (component.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode) || + filesNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + foreach (var fileEntry in filesNode.EnumerateObject()) + { + if (fileEntry.Value.ValueKind != JsonValueKind.Object) + { + 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) + { + continue; + } + + var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant(); + entries.Add(new PdcDownloadEntry( + component.Name, + fileEntry.Name, + downloadUrl, + hashHex)); + } + } + + return entries; + } + + private static async Task ComputeFileSha512HexAsync(string filePath, CancellationToken cancellationToken) + { + if (!File.Exists(filePath)) + { + return null; + } + + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var hashBytes = await SHA512.HashDataAsync(stream, cancellationToken); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + 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 string? ReadStringIgnoreCase(JsonElement node, string propertyName) + { + return TryGetPropertyIgnoreCase(node, propertyName, out var value) + ? value.ValueKind == JsonValueKind.String + ? value.GetString() + : value.ToString() + : null; + } + + private static byte[]? ReadByteArrayIgnoreCase(JsonElement node, string propertyName) + { + if (!TryGetPropertyIgnoreCase(node, propertyName, out var value)) + { + return null; + } + + return ReadByteArray(value); + } + + private static byte[]? ReadByteArray(JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + { + var text = value.GetString()?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + if (IsHexString(text)) + { + try + { + return Convert.FromHexString(text); + } + catch + { + // fall through to base64 + } + } + + try + { + return Convert.FromBase64String(text); + } + catch + { + return null; + } + } + case JsonValueKind.Array: + { + var bytes = new List(); + foreach (var item in value.EnumerateArray()) + { + if (!item.TryGetInt32(out var number) || number is < byte.MinValue or > byte.MaxValue) + { + return null; + } + + bytes.Add((byte)number); + } + + return bytes.ToArray(); + } + default: + return null; + } + } + + private static bool IsHexString(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length % 2 != 0) + { + return false; + } + + foreach (var ch in value) + { + if (!Uri.IsHexDigit(ch)) + { + return false; + } + } + + return true; + } + + private sealed record PdcDownloadEntry( + string ComponentId, + string RelativePath, + string DownloadUrl, + string ObjectHashHex); + + private sealed record PdcDownloadedObjectInfo( + string ComponentId, + string RelativePath, + string SourceUrl, + string ObjectHashHex, + string LocalPath); + + private sealed record PdcUpdateState( + string VersionText, + string DistributionId, + string ChannelId, + string SubChannel, + string FileMapPath, + string FileMapSignaturePath, + string ObjectsDirectory, + DateTimeOffset DownloadedAtUtc, + string FileMapJson, + string FileMapSignature, + IReadOnlyList Objects); + private static bool TryResolveDeltaAssets( IReadOnlyList assets, out GitHubReleaseAsset manifestAsset, @@ -327,6 +776,11 @@ public sealed class UpdateWorkflowService { ArgumentNullException.ThrowIfNull(checkResult); + if (checkResult.PdcPayload is not null) + { + return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); + } + if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null) { return new UpdateDownloadResult(false, null, "No compatible update asset is available."); @@ -365,9 +819,9 @@ public sealed class UpdateWorkflowService { PendingUpdateInstallerPath = result.FilePath ?? destinationPath, PendingUpdateVersion = checkResult.LatestVersionText, - PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue - ? null - : checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(), + PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue + ? publishedAt.ToUnixTimeMilliseconds() + : null, LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PendingUpdateSha256 = result.ActualHash }); @@ -383,6 +837,12 @@ public sealed class UpdateWorkflowService { ArgumentNullException.ThrowIfNull(checkResult); + if (checkResult.PdcPayload is not null) + { + ClearPendingUpdate(); + return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken); + } + if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null) { return new UpdateDownloadResult(false, null, "No compatible update asset is available."); @@ -426,9 +886,9 @@ public sealed class UpdateWorkflowService { PendingUpdateInstallerPath = result.FilePath ?? destinationPath, PendingUpdateVersion = checkResult.LatestVersionText, - PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue - ? null - : checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(), + PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue + ? publishedAt.ToUnixTimeMilliseconds() + : null, LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), PendingUpdateSha256 = result.ActualHash }); @@ -449,9 +909,27 @@ public sealed class UpdateWorkflowService if (!File.Exists(pending.InstallerPath)) { + 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); + 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, "Installer file does not exist."); } + if (IsPendingDeltaUpdate()) + { + return new UpdateVerifyResult(true, true, null, null, null); + } + var expectedHash = pending.Sha256; var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath); @@ -483,7 +961,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) + if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PdcPayload is null)) { return; } @@ -495,7 +973,7 @@ public sealed class UpdateWorkflowService string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase)) { // Prefer delta update if available (smaller download, faster) - if (IsDeltaUpdateAvailable(result.Release)) + if (IsDeltaUpdateAvailable(result)) { AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package."); await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken); @@ -519,6 +997,14 @@ public sealed class UpdateWorkflowService public UpdateInstallerLaunchResult LaunchPendingInstallerNow() { + if (IsPendingDeltaUpdate()) + { + var launchResult = LaunchLauncherForApplyUpdate(); + return launchResult + ? new UpdateInstallerLaunchResult(true, false, null) + : new UpdateInstallerLaunchResult(false, false, "Failed to launch updater for incremental update."); + } + return LaunchPendingInstaller(silent: false, exitApplicationAfterLaunch: true); } diff --git a/phainon.yml b/phainon.yml index f4aa909..9cb4a18 100644 --- a/phainon.yml +++ b/phainon.yml @@ -1,7 +1,5 @@ -# Phainon Distribution Center (PDC) publish configuration -# This file is intentionally conservative: Launcher remains installer/rollback authority. +# Phainon Distribution Center Client Configuration name: "LanMountainDesktop" - components: app: allowDiffUpdate: true @@ -13,17 +11,13 @@ components: includes: - "**" excludes: - - "app-*/**" - - ".launcher/update/incoming/**" - - "files.json" - - "files.json.sig" - - "update.zip" - + - "app*/**" + - "files*.json" + - "files*.json.sig" + - "update*.zip" variables: number: 0 - -# Replace these roots in CI/CD or environment-specific templates when enabling PDCC publish. -fileRepoRoot: "https://example.invalid/lanmountain/distribution-v1/repo/" -archiveRoot: "https://example.invalid/lanmountain/distribution-v1/$(primaryVersion)/$(version)/" -bucketKeyRoot: "lanmountain/distribution-v1/repo/" -archiveBucketKeyRoot: "lanmountain/distribution-v1/$(primaryVersion)/$(version)/" +fileRepoRoot: "__FILE_REPO_ROOT__" +archiveRoot: "__ARCHIVE_ROOT__" +bucketKeyRoot: "lanmountain/update/repo/" +archiveBucketKeyRoot: "lanmountain/update/installers/" diff --git a/scripts/Install-Pdcc.ps1 b/scripts/Install-Pdcc.ps1 new file mode 100644 index 0000000..a725045 --- /dev/null +++ b/scripts/Install-Pdcc.ps1 @@ -0,0 +1,101 @@ +param( + [string]$Repository = "ClassIsland/PhainonDistributionCenter", + [string]$AssetName = "out_app_linux_x64.zip", + [string]$Version = "", + [string]$OutputDir = (Join-Path $PSScriptRoot "..\pdcc") +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($Repository)) { + throw "Repository is required." +} + +if ([string]::IsNullOrWhiteSpace($AssetName)) { + throw "AssetName is required." +} + +$OutputDir = [System.IO.Path]::GetFullPath($OutputDir) +if (-not (Test-Path -LiteralPath $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} + +$clientName = if ($env:OS -eq "Windows_NT") { "PhainonDistributionCenter.Client.exe" } else { "PhainonDistributionCenter.Client" } +$clientPath = Join-Path $OutputDir $clientName +if (Test-Path -LiteralPath $clientPath) { + Write-Host "PDCC client already installed at $clientPath" + return +} + +$releaseTag = $Version +if ([string]::IsNullOrWhiteSpace($releaseTag)) { + $releaseTag = $env:PDC_CLIENT_VERSION +} + +if ([string]::IsNullOrWhiteSpace($releaseTag)) { + $releaseTag = $env:PDCC_VERSION +} + +if ([string]::IsNullOrWhiteSpace($releaseTag)) { + $releaseTag = $env:PDCC_version +} + +$tempDir = Join-Path $env:RUNNER_TEMP "pdcc-install" +if (Test-Path -LiteralPath $tempDir) { + Remove-Item -LiteralPath $tempDir -Recurse -Force +} +New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + +$zipPath = Join-Path $tempDir $AssetName + +if (Get-Command gh -ErrorAction SilentlyContinue) { + Write-Host "Downloading PDCC via gh release download from $Repository ..." + $ghArgs = @("release", "download", "--repo", $Repository, "--pattern", $AssetName, "--dir", $tempDir, "--clobber") + if (-not [string]::IsNullOrWhiteSpace($releaseTag)) { + $ghArgs = @("release", "download", $releaseTag, "--repo", $Repository, "--pattern", $AssetName, "--dir", $tempDir, "--clobber") + } + + & gh @ghArgs + if ($LASTEXITCODE -ne 0) { + throw "gh release download failed for $Repository/$AssetName." + } +} +else { + if ([string]::IsNullOrWhiteSpace($releaseTag)) { + throw "PDCC_VERSION is required when gh is unavailable." + } + + $downloadUrl = "https://github.com/$Repository/releases/download/$releaseTag/$AssetName" + Write-Host "Downloading PDCC from $downloadUrl ..." + Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath +} + +$extractDir = Join-Path $tempDir "extract" +if (Test-Path -LiteralPath $extractDir) { + Remove-Item -LiteralPath $extractDir -Recurse -Force +} +New-Item -ItemType Directory -Path $extractDir -Force | Out-Null +Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force + +$copied = $false +foreach ($file in Get-ChildItem -LiteralPath $extractDir -Recurse -File) { + if ($file.Name -ieq $clientName) { + Copy-Item -LiteralPath $file.FullName -Destination $clientPath -Force + $copied = $true + break + } +} + +if (-not $copied) { + throw "PDCC client executable not found in downloaded archive." +} + +if ($IsLinux) { + try { + chmod +x $clientPath | Out-Null + } + catch { + } +} + +Write-Host "PDCC installed to $clientPath" diff --git a/scripts/Prepare-PdccOut.ps1 b/scripts/Prepare-PdccOut.ps1 new file mode 100644 index 0000000..bb39af5 --- /dev/null +++ b/scripts/Prepare-PdccOut.ps1 @@ -0,0 +1,59 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SourceDir, + + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + [string]$PlatformKey = "", + + [string[]]$InstallerFiles = @() +) + +$ErrorActionPreference = "Stop" + +$SourceDir = [System.IO.Path]::GetFullPath($SourceDir) +$OutputDir = [System.IO.Path]::GetFullPath($OutputDir) + +if (-not (Test-Path -LiteralPath $SourceDir)) { + throw "Source directory not found: $SourceDir" +} + +if (Test-Path -LiteralPath $OutputDir) { + Remove-Item -LiteralPath $OutputDir -Recurse -Force +} +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + +$payloadRoot = if ([string]::IsNullOrWhiteSpace($PlatformKey)) { + $OutputDir +} else { + Join-Path $OutputDir $PlatformKey +} + +New-Item -ItemType Directory -Path $payloadRoot -Force | Out-Null +Get-ChildItem -LiteralPath $SourceDir -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $payloadRoot -Recurse -Force +} + +if ($InstallerFiles.Count -gt 0) { + $installerRoot = Join-Path $OutputDir "installers" + if (-not (Test-Path -LiteralPath $installerRoot)) { + New-Item -ItemType Directory -Path $installerRoot -Force | Out-Null + } + + foreach ($installer in $InstallerFiles) { + if ([string]::IsNullOrWhiteSpace($installer)) { + continue + } + + $installerPath = [System.IO.Path]::GetFullPath($installer) + if (-not (Test-Path -LiteralPath $installerPath)) { + throw "Installer file not found: $installerPath" + } + + $targetPath = Join-Path $installerRoot ([System.IO.Path]::GetFileName($installerPath)) + Copy-Item -LiteralPath $installerPath -Destination $targetPath -Force + } +} + +Write-Host "Prepared PDCC staging directory: $payloadRoot"