From f6a6f97e0b34149d4f442bcbb497aeb77285b6a7 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 20 Apr 2026 07:48:53 +0800 Subject: [PATCH] chore: migrate release pipeline to signed filemap and wire rainyun s3 --- .github/workflows/release.yml | 262 ++++++---- .../pdc-incremental-migration/checklist.md | 10 + .trae/specs/pdc-incremental-migration/spec.md | 30 ++ .../specs/pdc-incremental-migration/tasks.md | 12 + .../velopack-update-integration/checklist.md | 10 +- .../specs/velopack-update-integration/spec.md | 25 +- .../velopack-update-integration/tasks.md | 13 +- LanMountainDesktop.Launcher/AppJsonContext.cs | 3 - .../Models/ReleaseInfo.cs | 2 - .../Models/VelopackModels.cs | 23 - .../Services/Commands.cs | 20 - .../Services/UpdateCheckService.cs | 6 +- .../Services/UpdateEngineService.cs | 373 +------------- .../Models/AppSettingsSnapshot.cs | 2 +- .../Services/PdcReleaseUpdateService.cs | 464 ++++++++++++++++++ .../Settings/SettingsDomainServices.cs | 42 +- .../Services/UpdateSettingsValues.cs | 21 +- .../Services/UpdateWorkflowService.cs | 121 +++-- .../ViewModels/SettingsViewModels.cs | 25 +- phainon.yml | 29 ++ scripts/Generate-DeltaPackage.ps1 | 286 ++++++----- scripts/Sign-FileMap.ps1 | 79 ++- 22 files changed, 1078 insertions(+), 780 deletions(-) create mode 100644 .trae/specs/pdc-incremental-migration/checklist.md create mode 100644 .trae/specs/pdc-incremental-migration/spec.md create mode 100644 .trae/specs/pdc-incremental-migration/tasks.md delete mode 100644 LanMountainDesktop.Launcher/Models/VelopackModels.cs create mode 100644 LanMountainDesktop/Services/PdcReleaseUpdateService.cs create mode 100644 phainon.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a205da..cb383ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,6 @@ env: DOTNET_VERSION: '10.0.x' Solution_Name: LanMountainDesktop.slnx DOTNET_gcServer: 1 - ENABLE_LEGACY_DELTA_FALLBACK: 'false' jobs: prepare: @@ -318,56 +317,20 @@ jobs: Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB" shell: pwsh - - name: Install vpk - if: matrix.self_contained == true && matrix.arch == 'x64' + - name: Build Signed FileMap Update Package + if: matrix.self_contained == true run: | $ErrorActionPreference = "Stop" - dotnet tool uninstall --global vpk | Out-Null - if ($LASTEXITCODE -ne 0) { - Write-Host "vpk is not preinstalled, proceeding with fresh install." - } - dotnet tool install --global vpk --allow-roll-forward - "$env:USERPROFILE\\.dotnet\\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - $env:PATH = "$env:USERPROFILE\\.dotnet\\tools;$env:PATH" - vpk -h - shell: pwsh - - name: Prepare Previous Velopack Full Package - if: matrix.self_contained == true && matrix.arch == 'x64' - run: | - $outputDir = "velopack-output" - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - - try { - $headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" } - $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers - $previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1 - if ($previousRelease) { - $previousFull = $previousRelease.assets | - Where-Object { $_.name -like "*-full.nupkg" } | - Select-Object -First 1 - if ($previousFull) { - $dest = Join-Path $outputDir $previousFull.name - Invoke-WebRequest -Uri $previousFull.browser_download_url -OutFile $dest -Headers $headers - Write-Host "Downloaded previous package for Velopack delta generation." - } else { - Write-Host "No previous full package found. Velopack will generate full package only." - } - } - } catch { - Write-Host "Could not fetch previous release package: $_" - } - shell: pwsh - - - name: Build Velopack Packages - if: matrix.self_contained == true && matrix.arch == 'x64' - run: | $version = "${{ needs.prepare.outputs.version }}" $arch = "${{ matrix.arch }}" + $platform = "windows-$arch" $publishDir = "publish/windows-$arch" $appDir = "app-$version" $currentAppPath = Join-Path $publishDir $appDir - $outputDir = "velopack-output" + $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" @@ -375,54 +338,65 @@ jobs: } New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - vpk pack ` - --packId LanMountainDesktop ` - --packVersion $version ` - --packDir $currentAppPath ` - --mainExe LanMountainDesktop.exe ` - --outputDir $outputDir ` - --channel win ` - --noPortable ` - --skipVeloAppCheck - - if ($LASTEXITCODE -ne 0) { - Write-Error "Velopack packaging failed." - exit 1 - } - - Get-ChildItem -Path $outputDir -File | Select-Object Name,Length - shell: pwsh - - - name: Legacy Delta Fallback (disabled by default) - if: matrix.self_contained == true && matrix.arch == 'x64' && env.ENABLE_LEGACY_DELTA_FALLBACK == 'true' - run: | - $version = "${{ needs.prepare.outputs.version }}" - $arch = "${{ matrix.arch }}" - $publishDir = "publish/windows-$arch" - $appDir = "app-$version" - $currentAppPath = Join-Path $publishDir $appDir - $outputDir = "delta-output" - $scriptPath = "scripts/Generate-DeltaPackage.ps1" - - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - & $scriptPath ` + & $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" + $repoPublicKey = (Get-Content -Path $repoPublicKeyPath -Raw).Trim() + if ($repoPublicKey -ne $derivedPublicKey.Trim()) { + 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 Velopack Package - if: matrix.self_contained == true && matrix.arch == 'x64' + - name: Upload Signed FileMap Update Package + if: matrix.self_contained == true uses: actions/upload-artifact@v4 with: - name: release-velopack-windows-x64 + name: release-update-windows-${{ matrix.arch }} path: | - velopack-output/*.nupkg - velopack-output/releases.win.json - velopack-output/assets.win.json - velopack-output/RELEASES + 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 if-no-files-found: error retention-days: 90 - name: Upload Installer @@ -630,6 +604,86 @@ 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" + $repoPublicKey = (Get-Content -Path $repoPublicKeyPath -Raw).Trim() + if ($repoPublicKey -ne $derivedPublicKey.Trim()) { + 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 + uses: actions/upload-artifact@v4 + with: + name: release-update-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 + if-no-files-found: error + retention-days: 90 + - name: Upload uses: actions/upload-artifact@v4 with: @@ -832,8 +886,8 @@ jobs: 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 Velopack release feed and update packages - find artifacts -type f \( -name "releases.win.json" -o -name "assets.win.json" -o -name "RELEASES" -o -name "*.nupkg" \) -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/ \; echo "" echo "Files ready for release:" ls -lh release-files/ || echo "No files found in release-files" @@ -846,6 +900,38 @@ jobs: exit 1 fi + - name: Upload Incremental Assets to S3 (optional) + if: ${{ vars.S3_ENDPOINT != '' && vars.S3_BUCKET != '' && secrets.S3_ACCESS_KEY != '' && secrets.S3_SECRET_KEY != '' }} + 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 + 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: @@ -867,12 +953,12 @@ jobs: Installation: Double-click the .exe file and follow the wizard. - ### Incremental Update (Windows x64) - - **releases.win.json** - Velopack release feed consumed by the launcher update flow - - **LanMountainDesktop--full.nupkg** - full package - - **LanMountainDesktop--delta.nupkg** - delta package (when available) + ### Incremental Update Assets + - **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip** + - **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip** + - **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip** - Existing users: The app will automatically detect and apply the incremental update on next launch. + Existing users: Launcher will detect platform-matching signed assets and apply update on next startup. ### Linux - **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64) diff --git a/.trae/specs/pdc-incremental-migration/checklist.md b/.trae/specs/pdc-incremental-migration/checklist.md new file mode 100644 index 0000000..fabbce1 --- /dev/null +++ b/.trae/specs/pdc-incremental-migration/checklist.md @@ -0,0 +1,10 @@ +# 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. +- [ ] 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 new file mode 100644 index 0000000..ec308e8 --- /dev/null +++ b/.trae/specs/pdc-incremental-migration/spec.md @@ -0,0 +1,30 @@ +# PDC Incremental Update Migration + +## 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. + +## Stage 1 (Completed in this round) + +- 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. + +## Stage 2 (In Progress) + +- 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. + +## 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. diff --git a/.trae/specs/pdc-incremental-migration/tasks.md b/.trae/specs/pdc-incremental-migration/tasks.md new file mode 100644 index 0000000..aebd0db --- /dev/null +++ b/.trae/specs/pdc-incremental-migration/tasks.md @@ -0,0 +1,12 @@ +# 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. diff --git a/.trae/specs/velopack-update-integration/checklist.md b/.trae/specs/velopack-update-integration/checklist.md index 04b7278..2641ed1 100644 --- a/.trae/specs/velopack-update-integration/checklist.md +++ b/.trae/specs/velopack-update-integration/checklist.md @@ -1,7 +1,5 @@ -# Checklist +# Checklist (Deprecated) -- [x] `releases.win.json` recognized by host update download flow. -- [x] Launcher pending update check supports VeloPack payload. -- [x] Launcher apply uses deployment markers (`.current/.partial/.destroy`) unchanged. -- [x] Legacy script path retained as emergency fallback. -- [ ] Staging verification report attached. +- [x] Spec marked as deprecated. +- [x] Active implementation ownership moved to `pdc-incremental-migration`. +- [x] No release workflow dependency remains on VeloPack. diff --git a/.trae/specs/velopack-update-integration/spec.md b/.trae/specs/velopack-update-integration/spec.md index cb687c0..c080d00 100644 --- a/.trae/specs/velopack-update-integration/spec.md +++ b/.trae/specs/velopack-update-integration/spec.md @@ -1,16 +1,15 @@ -# VeloPack Update Integration +# VeloPack Update Integration (Deprecated) -## Goal -Switch incremental package generation and release assets to VeloPack native outputs while keeping Launcher as the update installer and rollback authority. +## Status -## Requirements -- CI/release pipeline produces `releases.win.json` and `*.nupkg` assets for Windows x64. -- Launcher can detect pending VeloPack payload in `.launcher/update/incoming`. -- Launcher applies update into new `app-*` deployment and preserves rollback snapshot behavior. -- Existing launcher responsibilities (OOBE/startup/plugin upgrade) remain unchanged. +This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`. -## Acceptance -- Build and quality workflows pass after migration changes. -- Release workflow publishes VeloPack assets. -- Launcher `update apply` succeeds with VeloPack full package payload. -- Manual rollback still works after a VeloPack-based update. +## Deprecation Reason + +- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence). +- The project has switched back to signed FileMap incremental assets as the primary update path. +- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. + +## Migration Note + +Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance. diff --git a/.trae/specs/velopack-update-integration/tasks.md b/.trae/specs/velopack-update-integration/tasks.md index 2799d02..8ac2548 100644 --- a/.trae/specs/velopack-update-integration/tasks.md +++ b/.trae/specs/velopack-update-integration/tasks.md @@ -1,9 +1,6 @@ -# Tasks +# Tasks (Deprecated) -- [x] Fix Launcher `LoadingDetailsWindow.axaml` compile regression. -- [x] Add VeloPack feed/package model support in Launcher update engine. -- [x] Keep legacy delta flow behind disabled fallback switch. -- [x] Migrate release workflow packaging assets to VeloPack outputs. -- [x] Update host-side update workflow to download VeloPack payload files. -- [ ] Run full release workflow dry-run on GitHub and validate artifacts. -- [ ] Validate end-to-end update + rollback on a staging machine. +- [x] Mark VeloPack integration spec as deprecated. +- [x] Remove VeloPack runtime branches from launcher/host update path. +- [x] Remove VeloPack release workflow packaging steps. +- [ ] Keep archive for historical context only (no new implementation tasks here). diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 210d0e0..7b02ce9 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -20,7 +20,4 @@ namespace LanMountainDesktop.Launcher; [JsonSerializable(typeof(GitHubRelease))] [JsonSerializable(typeof(GitHubAsset))] [JsonSerializable(typeof(List))] -[JsonSerializable(typeof(VelopackReleaseFeed))] -[JsonSerializable(typeof(VelopackReleaseAsset))] -[JsonSerializable(typeof(List))] internal sealed partial class AppJsonContext : JsonSerializerContext; diff --git a/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs b/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs index 15fcb46..b74348a 100644 --- a/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs +++ b/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs @@ -11,8 +11,6 @@ public sealed class ReleaseInfo public required DateTime PublishedAt { get; init; } public required List Assets { get; init; } public string? Body { get; init; } - public string? VelopackFeedUrl { get; init; } - public string? VelopackLegacyReleasesUrl { get; init; } } /// diff --git a/LanMountainDesktop.Launcher/Models/VelopackModels.cs b/LanMountainDesktop.Launcher/Models/VelopackModels.cs deleted file mode 100644 index 7d568e1..0000000 --- a/LanMountainDesktop.Launcher/Models/VelopackModels.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace LanMountainDesktop.Launcher.Models; - -internal sealed class VelopackReleaseFeed -{ - public List Assets { get; set; } = []; -} - -internal sealed class VelopackReleaseAsset -{ - public string PackageId { get; set; } = string.Empty; - - public string Version { get; set; } = string.Empty; - - public string Type { get; set; } = string.Empty; - - public string FileName { get; set; } = string.Empty; - - public string? SHA1 { get; set; } - - public string? SHA256 { get; set; } - - public long Size { get; set; } -} diff --git a/LanMountainDesktop.Launcher/Services/Commands.cs b/LanMountainDesktop.Launcher/Services/Commands.cs index 14289ff..1237b74 100644 --- a/LanMountainDesktop.Launcher/Services/Commands.cs +++ b/LanMountainDesktop.Launcher/Services/Commands.cs @@ -104,26 +104,6 @@ internal static class Commands private static async Task DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine) { - var releasesUrl = context.GetOption("releases-url"); - if (!string.IsNullOrWhiteSpace(releasesUrl)) - { - var packageUrls = new List(); - var packageUrl = context.GetOption("package-url"); - if (!string.IsNullOrWhiteSpace(packageUrl)) - { - packageUrls.Add(packageUrl); - } - - var packageUrlsCsv = context.GetOption("package-urls"); - if (!string.IsNullOrWhiteSpace(packageUrlsCsv)) - { - packageUrls.AddRange(packageUrlsCsv - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); - } - - return await updateEngine.DownloadVelopackAsync(releasesUrl, packageUrls, CancellationToken.None).ConfigureAwait(false); - } - return await updateEngine.DownloadAsync( context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."), context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."), diff --git a/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs b/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs index 9a17d4b..5f4e3dd 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs @@ -104,11 +104,7 @@ internal sealed class UpdateCheckService Name = a.Name ?? "", BrowserDownloadUrl = a.BrowserDownloadUrl ?? "", Size = a.Size - }).ToList() ?? [], - VelopackFeedUrl = r.Assets?.FirstOrDefault(a => - string.Equals(a.Name, "releases.win.json", StringComparison.OrdinalIgnoreCase))?.BrowserDownloadUrl, - VelopackLegacyReleasesUrl = r.Assets?.FirstOrDefault(a => - string.Equals(a.Name, "RELEASES", StringComparison.OrdinalIgnoreCase))?.BrowserDownloadUrl + }).ToList() ?? [] }).ToList() ?? []; } diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index 32d2aa8..a637a80 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -14,7 +14,6 @@ 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 VelopackReleasesFileName = "releases.win.json"; private const string PublicKeyFileName = "public-key.pem"; private readonly DeploymentLocator _deploymentLocator; @@ -34,16 +33,6 @@ internal sealed class UpdateEngineService public LauncherResult CheckPendingUpdate() { - var velopackFeedPath = Path.Combine(_incomingRoot, VelopackReleasesFileName); - if (File.Exists(velopackFeedPath)) - { - var velopackResult = CheckVelopackPendingUpdate(velopackFeedPath); - if (velopackResult is not null) - { - return velopackResult; - } - } - var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); @@ -82,47 +71,6 @@ internal sealed class UpdateEngineService }; } - public async Task DownloadVelopackAsync( - string releasesJsonUrl, - IReadOnlyList packageUrls, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(releasesJsonUrl)) - { - return Failed("update.download", "invalid_argument", "Missing releases feed url."); - } - - Directory.CreateDirectory(_incomingRoot); - - using var client = new HttpClient - { - Timeout = TimeSpan.FromMinutes(2) - }; - - var releasesPath = Path.Combine(_incomingRoot, VelopackReleasesFileName); - await DownloadToFileAsync(client, releasesJsonUrl, releasesPath, cancellationToken).ConfigureAwait(false); - - foreach (var url in packageUrls.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct(StringComparer.OrdinalIgnoreCase)) - { - var fileName = Path.GetFileName(new Uri(url).AbsolutePath); - if (string.IsNullOrWhiteSpace(fileName)) - { - continue; - } - - var destination = Path.Combine(_incomingRoot, fileName); - await DownloadToFileAsync(client, url, destination, cancellationToken).ConfigureAwait(false); - } - - return new LauncherResult - { - Success = true, - Stage = "update.download", - Code = "ok", - Message = "Velopack update payload downloaded." - }; - } - public async Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken) { Directory.CreateDirectory(_incomingRoot); @@ -167,12 +115,6 @@ internal sealed class UpdateEngineService Directory.CreateDirectory(_incomingRoot); Directory.CreateDirectory(_snapshotsRoot); - var velopackFeedPath = Path.Combine(_incomingRoot, VelopackReleasesFileName); - if (File.Exists(velopackFeedPath)) - { - return await ApplyVelopackPendingUpdateAsync(velopackFeedPath).ConfigureAwait(false); - } - var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); @@ -631,8 +573,7 @@ internal sealed class UpdateEngineService { Path.Combine(_incomingRoot, SignedFileMapName), Path.Combine(_incomingRoot, SignatureFileName), - Path.Combine(_incomingRoot, ArchiveFileName), - Path.Combine(_incomingRoot, VelopackReleasesFileName) + Path.Combine(_incomingRoot, ArchiveFileName) }) { try @@ -646,17 +587,6 @@ internal sealed class UpdateEngineService { } } - - try - { - foreach (var nupkgPath in Directory.EnumerateFiles(_incomingRoot, "*.nupkg", SearchOption.TopDirectoryOnly)) - { - File.Delete(nupkgPath); - } - } - catch - { - } } private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath) @@ -724,307 +654,6 @@ internal sealed class UpdateEngineService return Convert.ToHexString(hash).ToLowerInvariant(); } - private LauncherResult? CheckVelopackPendingUpdate(string feedPath) - { - try - { - var feed = JsonSerializer.Deserialize(File.ReadAllText(feedPath), AppJsonContext.Default.VelopackReleaseFeed); - if (feed?.Assets is null || feed.Assets.Count == 0) - { - return Failed("update.check", "invalid_manifest", "releases.win.json is invalid."); - } - - var currentVersion = ParseVersionSafe(_deploymentLocator.GetCurrentVersion()); - var latest = feed.Assets - .Where(a => string.Equals(a.Type, "Full", StringComparison.OrdinalIgnoreCase)) - .Select(a => new { Asset = a, Version = ParseVersionSafe(a.Version) }) - .Where(x => x.Version > currentVersion) - .OrderByDescending(x => x.Version) - .FirstOrDefault(); - - if (latest is null) - { - return new LauncherResult - { - Success = true, - Stage = "update.check", - Code = "noop", - Message = "No pending update for current version." - }; - } - - var packagePath = Path.Combine(_incomingRoot, latest.Asset.FileName); - if (!File.Exists(packagePath)) - { - return Failed("update.check", "missing_payload", $"Missing Velopack package '{latest.Asset.FileName}'."); - } - - return new LauncherResult - { - Success = true, - Stage = "update.check", - Code = "available", - Message = "Pending Velopack update is available.", - CurrentVersion = _deploymentLocator.GetCurrentVersion(), - TargetVersion = latest.Asset.Version - }; - } - catch (Exception ex) - { - return Failed("update.check", "invalid_manifest", ex.Message); - } - } - - private async Task ApplyVelopackPendingUpdateAsync(string feedPath) - { - VelopackReleaseFeed? feed; - try - { - var json = await File.ReadAllTextAsync(feedPath).ConfigureAwait(false); - feed = JsonSerializer.Deserialize(json, AppJsonContext.Default.VelopackReleaseFeed); - } - catch (Exception ex) - { - return Failed("update.apply", "invalid_manifest", $"Invalid releases feed: {ex.Message}"); - } - - if (feed?.Assets is null || feed.Assets.Count == 0) - { - return Failed("update.apply", "invalid_manifest", "releases.win.json has no assets."); - } - - var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); - if (string.IsNullOrWhiteSpace(currentDeployment)) - { - return Failed("update.apply", "no_current_deployment", "Current deployment not found."); - } - - var currentVersionText = _deploymentLocator.GetCurrentVersion(); - var currentVersion = ParseVersionSafe(currentVersionText); - var target = feed.Assets - .Where(a => string.Equals(a.Type, "Full", StringComparison.OrdinalIgnoreCase)) - .Select(a => new { Asset = a, Version = ParseVersionSafe(a.Version) }) - .Where(x => x.Version > currentVersion) - .OrderByDescending(x => x.Version) - .FirstOrDefault(); - - if (target is null) - { - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "noop", - Message = "No Velopack update payload found." - }; - } - - var packagePath = Path.Combine(_incomingRoot, target.Asset.FileName); - if (!File.Exists(packagePath)) - { - return Failed("update.apply", "missing_payload", $"Missing Velopack package '{target.Asset.FileName}'."); - } - - if (!VerifyVelopackPackageChecksum(packagePath, target.Asset)) - { - return Failed("update.apply", "checksum_failed", "Velopack package checksum verification failed."); - } - - var targetVersion = string.IsNullOrWhiteSpace(target.Asset.Version) ? currentVersionText : target.Asset.Version; - var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion); - var partialMarker = Path.Combine(targetDeployment, ".partial"); - var snapshot = new SnapshotMetadata - { - SnapshotId = Guid.NewGuid().ToString("N"), - SourceVersion = currentVersionText, - TargetVersion = targetVersion, - CreatedAt = DateTimeOffset.UtcNow, - SourceDirectory = currentDeployment, - TargetDirectory = targetDeployment, - Status = "pending" - }; - var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json"); - var extractRoot = Path.Combine(_incomingRoot, "extracted-velopack"); - - try - { - SaveSnapshot(snapshotPath, snapshot); - - if (Directory.Exists(extractRoot)) - { - Directory.Delete(extractRoot, true); - } - - Directory.CreateDirectory(extractRoot); - ZipFile.ExtractToDirectory(packagePath, extractRoot, overwriteFiles: true); - - var contentRoot = ResolveVelopackContentRoot(extractRoot); - if (contentRoot is null) - { - throw new InvalidOperationException("Unable to locate app payload in Velopack package."); - } - - Directory.CreateDirectory(targetDeployment); - File.WriteAllText(partialMarker, string.Empty); - CopyDirectory(contentRoot, targetDeployment); - - var hostExecutable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; - if (!File.Exists(Path.Combine(targetDeployment, hostExecutable))) - { - throw new InvalidOperationException($"Host executable '{hostExecutable}' not found after applying Velopack package."); - } - - 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 = currentVersionText, - TargetVersion = targetVersion - }; - } - catch (Exception ex) - { - TryRollbackOnFailure(snapshot); - snapshot.Status = "rolled_back"; - SaveSnapshot(snapshotPath, snapshot); - return new LauncherResult - { - Success = false, - Stage = "update.apply", - Code = "apply_failed", - Message = "Failed to apply update. Rolled back to previous version.", - ErrorMessage = ex.Message, - CurrentVersion = currentVersionText, - RolledBackTo = currentVersionText - }; - } - finally - { - try - { - if (Directory.Exists(extractRoot)) - { - Directory.Delete(extractRoot, true); - } - } - catch - { - } - } - } - - private static Version ParseVersionSafe(string? version) - { - if (string.IsNullOrWhiteSpace(version)) - { - return new Version(0, 0, 0); - } - - var normalized = version.Trim(); - var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']); - if (separatorIndex > 0) - { - normalized = normalized[..separatorIndex]; - } - - return Version.TryParse(normalized, out var parsed) ? parsed : new Version(0, 0, 0); - } - - private static bool VerifyVelopackPackageChecksum(string packagePath, VelopackReleaseAsset asset) - { - try - { - if (!string.IsNullOrWhiteSpace(asset.SHA256)) - { - var actualSha256 = ComputeSha256Hex(packagePath); - return string.Equals(actualSha256, asset.SHA256, StringComparison.OrdinalIgnoreCase); - } - - if (!string.IsNullOrWhiteSpace(asset.SHA1)) - { - using var stream = File.OpenRead(packagePath); - var sha1 = SHA1.HashData(stream); - var actualSha1 = Convert.ToHexString(sha1); - return string.Equals(actualSha1, asset.SHA1, StringComparison.OrdinalIgnoreCase); - } - - return true; - } - catch - { - return false; - } - } - - private static string? ResolveVelopackContentRoot(string extractRoot) - { - var hostExecutable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; - var hostPath = Directory - .EnumerateFiles(extractRoot, hostExecutable, SearchOption.AllDirectories) - .FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(hostPath)) - { - return Path.GetDirectoryName(hostPath); - } - - // common nupkg layout fallback - var libRoot = Path.Combine(extractRoot, "lib"); - if (Directory.Exists(libRoot)) - { - var best = Directory.GetDirectories(libRoot, "*", SearchOption.TopDirectoryOnly) - .OrderByDescending(d => Directory.EnumerateFiles(d, "*", SearchOption.AllDirectories).Count()) - .FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(best)) - { - return best; - } - } - - var candidate = Directory.GetDirectories(extractRoot, "*", SearchOption.TopDirectoryOnly) - .Where(d => !string.Equals(Path.GetFileName(d), "_rels", StringComparison.OrdinalIgnoreCase)) - .Where(d => !string.Equals(Path.GetFileName(d), "package", StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(d => Directory.EnumerateFiles(d, "*", SearchOption.AllDirectories).Count()) - .FirstOrDefault(); - - return candidate; - } - - private static void CopyDirectory(string sourceDir, string targetDir) - { - foreach (var dirPath in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories)) - { - var relative = Path.GetRelativePath(sourceDir, dirPath); - Directory.CreateDirectory(Path.Combine(targetDir, relative)); - } - - foreach (var sourceFile in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) - { - var relative = Path.GetRelativePath(sourceDir, sourceFile); - var destFile = Path.Combine(targetDir, relative); - var destDir = Path.GetDirectoryName(destFile); - if (!string.IsNullOrWhiteSpace(destDir)) - { - Directory.CreateDirectory(destDir); - } - File.Copy(sourceFile, destFile, overwrite: true); - } - } - - private static async Task DownloadToFileAsync(HttpClient client, string url, string destination, CancellationToken cancellationToken) - { - await using var stream = await client.GetStreamAsync(url, cancellationToken).ConfigureAwait(false); - await using var output = File.Create(destination); - await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); - } - 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 c487059..8902aab 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; } = "github"; + public string UpdateDownloadSource { get; set; } = "pdc"; public int UpdateDownloadThreads { get; set; } = 4; diff --git a/LanMountainDesktop/Services/PdcReleaseUpdateService.cs b/LanMountainDesktop/Services/PdcReleaseUpdateService.cs new file mode 100644 index 0000000..571053e --- /dev/null +++ b/LanMountainDesktop/Services/PdcReleaseUpdateService.cs @@ -0,0 +1,464 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMountainDesktop.Services; + +/// +/// Best-effort PDC client that maps PDC responses to the existing update result model. +/// This keeps launcher update contracts stable while allowing a gradual migration. +/// +public sealed class PdcReleaseUpdateService : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + + public PdcReleaseUpdateService(HttpClient? httpClient = null) + { + if (httpClient is null) + { + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20) + }; + _ownsHttpClient = true; + } + else + { + _httpClient = httpClient; + _ownsHttpClient = false; + } + } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + public Task CheckForUpdatesAsync( + Version currentVersion, + bool includePrerelease, + CancellationToken cancellationToken = default) + { + return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken); + } + + public Task ForceCheckForUpdatesAsync( + Version currentVersion, + bool includePrerelease, + CancellationToken cancellationToken = default) + { + return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); + } + + private async Task CheckForUpdatesCoreAsync( + Version currentVersion, + bool includePrerelease, + bool isForce, + CancellationToken cancellationToken) + { + var normalizedCurrentVersion = NormalizeVersion(currentVersion); + var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion); + var endpoint = ResolveEndpoint(); + + if (string.IsNullOrWhiteSpace(endpoint)) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: "-", + Release: null, + PreferredAsset: null, + ErrorMessage: "PDC endpoint is not configured.", + ForceMode: isForce); + } + + try + { + var metadataUrl = BuildUri(endpoint, "api/v1/public/distributions/metadata"); + var metadata = await GetContentNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false); + + var channelId = ResolveChannelId(metadata, includePrerelease); + if (string.IsNullOrWhiteSpace(channelId)) + { + channelId = includePrerelease ? "preview" : "stable"; + } + + var latestUrl = BuildUri( + endpoint, + $"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}"); + var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false); + + var latestVersionText = ReadString(latestNode, "version") ?? "-"; + if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: latestVersionText, + Release: null, + PreferredAsset: null, + ErrorMessage: "PDC latest distribution version is invalid.", + ForceMode: isForce); + } + + var distributionId = ReadString(latestNode, "distributionId"); + if (string.IsNullOrWhiteSpace(distributionId)) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: latestVersionText, + Release: null, + PreferredAsset: null, + ErrorMessage: "PDC latest distribution id is missing.", + ForceMode: isForce); + } + + var hasUpdate = latestVersion > normalizedCurrentVersion; + if (!isForce && !hasUpdate) + { + return new UpdateCheckResult( + Success: true, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: latestVersionText, + Release: null, + PreferredAsset: null, + ErrorMessage: null, + ForceMode: false); + } + + var subChannel = ResolveSubChannel(); + var distributionUrl = BuildUri( + endpoint, + $"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}"); + var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false); + + var assets = ResolveAssets(distributionNode); + if (assets.Count == 0) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: latestVersionText, + Release: null, + PreferredAsset: null, + ErrorMessage: "PDC distribution response does not expose downloadable update assets.", + ForceMode: isForce); + } + + var release = new GitHubReleaseInfo( + TagName: $"v{latestVersionText}", + Name: $"PDC Distribution {latestVersionText}", + IsPrerelease: includePrerelease, + IsDraft: false, + PublishedAt: DateTimeOffset.UtcNow, + Assets: assets); + + return new UpdateCheckResult( + Success: true, + IsUpdateAvailable: true, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: latestVersionText, + Release: release, + PreferredAsset: null, + ErrorMessage: null, + ForceMode: isForce); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: "-", + Release: null, + PreferredAsset: null, + ErrorMessage: $"PDC request failed: {ex.Message}", + ForceMode: isForce); + } + } + + private async Task GetContentNodeAsync(string url, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + var token = ResolveToken(); + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}"); + } + + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("content", out var content)) + { + return content.Clone(); + } + + return root.Clone(); + } + + private static IReadOnlyList ResolveAssets(JsonElement distributionNode) + { + var assets = new List(); + if (distributionNode.ValueKind != JsonValueKind.Object) + { + return assets; + } + + if (distributionNode.TryGetProperty("assets", out var assetsNode) && + assetsNode.ValueKind == JsonValueKind.Array) + { + foreach (var assetNode in assetsNode.EnumerateArray()) + { + if (assetNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + var name = ReadString(assetNode, "name"); + var url = ReadString(assetNode, "url") ?? + ReadString(assetNode, "downloadUrl") ?? + ReadString(assetNode, "browserDownloadUrl"); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) + { + continue; + } + + var size = ReadInt64(assetNode, "size") ?? 0L; + var sha256 = ReadString(assetNode, "sha256"); + assets.Add(new GitHubReleaseAsset(name, url, size, sha256)); + } + } + + if (assets.Count > 0) + { + return assets; + } + + // Field-level fallback for service-side URL projection. + var manifestUrl = ReadString(distributionNode, "manifestUrl") + ?? ReadString(distributionNode, "fileMapUrl"); + var signatureUrl = ReadString(distributionNode, "signatureUrl") + ?? ReadString(distributionNode, "fileMapSignatureUrl"); + var archiveUrl = ReadString(distributionNode, "archiveUrl") + ?? ReadString(distributionNode, "updateArchiveUrl") + ?? ReadString(distributionNode, "payloadUrl"); + + if (!string.IsNullOrWhiteSpace(manifestUrl)) + { + assets.Add(new GitHubReleaseAsset("files.json", manifestUrl, 0, null)); + } + + if (!string.IsNullOrWhiteSpace(signatureUrl)) + { + assets.Add(new GitHubReleaseAsset("files.json.sig", signatureUrl, 0, null)); + } + + if (!string.IsNullOrWhiteSpace(archiveUrl)) + { + assets.Add(new GitHubReleaseAsset("update.zip", archiveUrl, 0, null)); + } + + return assets; + } + + private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease) + { + if (metadataNode.ValueKind != JsonValueKind.Object || + !metadataNode.TryGetProperty("channels", out var channelsNode)) + { + return includePrerelease ? "preview" : "stable"; + } + + var defaultChannelId = ReadString(metadataNode, "defaultChannelId") ?? string.Empty; + if (channelsNode.ValueKind != JsonValueKind.Object) + { + return defaultChannelId; + } + + string? matchedPreview = null; + string? matchedStable = null; + + foreach (var channel in channelsNode.EnumerateObject()) + { + var name = ReadString(channel.Value, "name") ?? channel.Name; + if (string.IsNullOrWhiteSpace(matchedPreview) && + (name.Contains("preview", StringComparison.OrdinalIgnoreCase) || + name.Contains("beta", StringComparison.OrdinalIgnoreCase) || + name.Contains("dev", StringComparison.OrdinalIgnoreCase))) + { + matchedPreview = channel.Name; + } + + if (string.IsNullOrWhiteSpace(matchedStable) && + (name.Contains("stable", StringComparison.OrdinalIgnoreCase) || + name.Contains("release", StringComparison.OrdinalIgnoreCase))) + { + matchedStable = channel.Name; + } + } + + if (includePrerelease) + { + return matchedPreview ?? defaultChannelId ?? "preview"; + } + + return matchedStable ?? defaultChannelId ?? "stable"; + } + + private static string ResolveSubChannel() + { + var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_SUBCHANNEL") + ?? Environment.GetEnvironmentVariable("PDC_SUBCHANNEL"); + if (!string.IsNullOrWhiteSpace(configured)) + { + return configured.Trim(); + } + + var os = OperatingSystem.IsWindows() + ? "windows" + : OperatingSystem.IsLinux() + ? "linux" + : OperatingSystem.IsMacOS() + ? "macos" + : "unknown"; + + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X86 => "x86", + Architecture.Arm => "arm", + Architecture.Arm64 => "arm64", + _ => "x64" + }; + + return $"{os}_{arch}_release_folderClassic"; + } + + private static string? ResolveEndpoint() + { + var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT") + ?? Environment.GetEnvironmentVariable("PDC_ENDPOINT"); + return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/'); + } + + private static string? ResolveToken() + { + var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN") + ?? Environment.GetEnvironmentVariable("PDC_TOKEN"); + return string.IsNullOrWhiteSpace(token) ? null : token.Trim(); + } + + private static string BuildUri(string endpoint, string relativePath) + { + return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}"; + } + + private static string? ReadString(JsonElement node, string propertyName) + { + if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString() + : value.ToString(); + } + + private static long? ReadInt64(JsonElement node, string propertyName) + { + if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value)) + { + return null; + } + + if (value.TryGetInt64(out var number)) + { + return number; + } + + var text = value.ToString(); + return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + + private static bool TryParseVersion(string? value, out Version? version) + { + version = null; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Trim().TrimStart('v', 'V'); + var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']); + if (separatorIndex > 0) + { + normalized = normalized[..separatorIndex]; + } + + if (!Version.TryParse(normalized, out var parsed)) + { + return false; + } + + version = NormalizeVersion(parsed); + return true; + } + + private static Version NormalizeVersion(Version version) + { + var major = Math.Max(0, version.Major); + var minor = Math.Max(0, version.Minor); + var build = Math.Max(0, version.Build >= 0 ? version.Build : 0); + var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0); + return revision > 0 + ? new Version(major, minor, build, revision) + : new Version(major, minor, build); + } + + private static string FormatVersionText(Version version) + { + return version.Revision > 0 + ? version.ToString(4) + : version.ToString(3); + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } +} diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index c2547b3..35f2784 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -751,7 +751,8 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable { private readonly ISettingsService _settingsService; - private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop"); + private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop"); + private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new(); public UpdateSettingsService(ISettingsService settingsService) { @@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl bool includePrerelease, CancellationToken cancellationToken = default) { - return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); + return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken); } public Task ForceCheckForUpdatesAsync( @@ -838,7 +839,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl bool includePrerelease, CancellationToken cancellationToken = default) { - return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); + return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken); } public Task DownloadAssetAsync( @@ -849,7 +850,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl IProgress? progress = null, CancellationToken cancellationToken = default) { - return _releaseUpdateService.DownloadAssetAsync( + return _githubReleaseUpdateService.DownloadAssetAsync( asset, destinationFilePath, downloadSource, @@ -866,7 +867,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl IProgress? progress = null, CancellationToken cancellationToken = default) { - return _releaseUpdateService.RedownloadAssetAsync( + return _githubReleaseUpdateService.RedownloadAssetAsync( asset, destinationFilePath, downloadSource, @@ -877,7 +878,36 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl public void Dispose() { - _releaseUpdateService.Dispose(); + _githubReleaseUpdateService.Dispose(); + _pdcReleaseUpdateService.Dispose(); + } + + private async Task CheckForUpdatesCoreAsync( + Version currentVersion, + bool includePrerelease, + bool isForce, + CancellationToken cancellationToken) + { + var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource); + if (string.Equals(source, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase)) + { + var pdcResult = isForce + ? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) + : await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); + + if (pdcResult.Success) + { + return pdcResult; + } + + AppLogger.Warn( + "UpdateSettings", + $"PDC update check failed and will fallback to GitHub. Error: {pdcResult.ErrorMessage}"); + } + + return isForce + ? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken) + : await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); } } diff --git a/LanMountainDesktop/Services/UpdateSettingsValues.cs b/LanMountainDesktop/Services/UpdateSettingsValues.cs index bb5e7f4..0a5af37 100644 --- a/LanMountainDesktop/Services/UpdateSettingsValues.cs +++ b/LanMountainDesktop/Services/UpdateSettingsValues.cs @@ -11,6 +11,7 @@ public static class UpdateSettingsValues public const string ModeDownloadThenConfirm = "download_then_confirm"; public const string ModeSilentOnExit = "silent_on_exit"; + public const string DownloadSourcePdc = "pdc"; public const string DownloadSourceGitHub = "github"; public const string DownloadSourceGhProxy = "gh-proxy"; @@ -51,9 +52,23 @@ public static class UpdateSettingsValues public static string NormalizeDownloadSource(string? value) { - return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase) - ? DownloadSourceGhProxy - : DownloadSourceGitHub; + if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase)) + { + return DownloadSourcePdc; + } + + if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)) + { + return DownloadSourceGhProxy; + } + + if (string.Equals(value, DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase)) + { + return DownloadSourceGitHub; + } + + // Default to PDC. Runtime will fallback to GitHub if PDC is unavailable. + return DownloadSourcePdc; } public static int NormalizeDownloadThreads(int value) diff --git a/LanMountainDesktop/Services/UpdateWorkflowService.cs b/LanMountainDesktop/Services/UpdateWorkflowService.cs index d8ecb34..42b2154 100644 --- a/LanMountainDesktop/Services/UpdateWorkflowService.cs +++ b/LanMountainDesktop/Services/UpdateWorkflowService.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.PluginSdk; @@ -52,7 +53,9 @@ public sealed class UpdateWorkflowService private const string LauncherDirectoryName = ".launcher"; private const string UpdateDirectoryName = "update"; private const string IncomingDirectoryName = "incoming"; - private const string VelopackReleasesFileName = "releases.win.json"; + private const string SignedFileMapName = "files.json"; + private const string SignedFileMapSignatureName = "files.json.sig"; + private const string UpdateArchiveName = "update.zip"; public UpdateWorkflowService(ISettingsFacadeService settingsFacade) { @@ -79,7 +82,7 @@ public sealed class UpdateWorkflowService } /// - /// Checks whether a GitHub Release contains Velopack assets needed for incremental updates. + /// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates. /// public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release) { @@ -88,13 +91,11 @@ public sealed class UpdateWorkflowService return false; } - var hasFeed = release.Assets.Any(a => string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase)); - var hasFull = release.Assets.Any(a => a.Name.EndsWith("-full.nupkg", StringComparison.OrdinalIgnoreCase)); - return hasFeed && hasFull; + return TryResolveDeltaAssets(release.Assets, out _, out _, out _); } /// - /// Downloads Velopack release feed and package files to the Launcher's incoming directory. + /// Downloads signed file-map assets to the Launcher's incoming directory. /// public async Task DownloadDeltaUpdateAsync( UpdateCheckResult checkResult, @@ -108,11 +109,9 @@ public sealed class UpdateWorkflowService return new UpdateDownloadResult(false, null, "No update available for delta download."); } - var releasesFeedAsset = checkResult.Release.Assets.FirstOrDefault(a => - string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase)); - if (releasesFeedAsset is null) + if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset)) { - return new UpdateDownloadResult(false, null, "Release does not contain releases.win.json."); + return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets."); } var incomingDir = GetLauncherIncomingDirectory(); @@ -130,29 +129,19 @@ public sealed class UpdateWorkflowService var downloadSource = state.UpdateDownloadSource; var downloadThreads = state.UpdateDownloadThreads; - var latestVersionText = checkResult.LatestVersionText.Trim(); - var targetPackages = checkResult.Release.Assets - .Where(a => a.Name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)) - .Where(a => a.Name.Contains(latestVersionText, StringComparison.OrdinalIgnoreCase)) - .Where(a => - a.Name.EndsWith("-full.nupkg", StringComparison.OrdinalIgnoreCase) || - a.Name.EndsWith("-delta.nupkg", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (targetPackages.Count == 0) + var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)> { - return new UpdateDownloadResult(false, null, "No Velopack nupkg asset found for the target version."); - } - - var requiredAssets = new List { releasesFeedAsset }; - requiredAssets.AddRange(targetPackages); + (manifestAsset, SignedFileMapName), + (signatureAsset, SignedFileMapSignatureName), + (archiveAsset, UpdateArchiveName) + }; var totalAssets = requiredAssets.Count; var completedAssets = 0; - foreach (var asset in requiredAssets) + foreach (var (asset, destinationFileName) in requiredAssets) { - var destinationPath = Path.Combine(incomingDir, asset.Name); + var destinationPath = Path.Combine(incomingDir, destinationFileName); // Skip if already downloaded and file exists if (File.Exists(destinationPath)) @@ -160,7 +149,7 @@ public sealed class UpdateWorkflowService var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken); if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase)) { - AppLogger.Info("UpdateWorkflow", $"Velopack asset {asset.Name} already downloaded with matching hash, skipping."); + AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping."); completedAssets++; progress?.Report((double)completedAssets / totalAssets); continue; @@ -184,21 +173,21 @@ public sealed class UpdateWorkflowService if (!result.Success) { // Clean up partially downloaded files - foreach (var file in requiredAssets.Select(a => a.Name)) + foreach (var file in requiredAssets.Select(a => a.DestinationFileName)) { try { File.Delete(Path.Combine(incomingDir, file)); } catch { } } - return new UpdateDownloadResult(false, null, $"Failed to download Velopack asset {asset.Name}: {result.ErrorMessage}"); + return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}"); } completedAssets++; progress?.Report((double)completedAssets / totalAssets); } - // Save state indicating a Velopack update is pending. + // Save state indicating a signed file-map update is pending. SaveState(state with { - PendingUpdateInstallerPath = Path.Combine(incomingDir, VelopackReleasesFileName), + PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName), PendingUpdateVersion = checkResult.LatestVersionText, PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue ? null @@ -207,9 +196,9 @@ public sealed class UpdateWorkflowService PendingUpdateSha256 = null }); - AppLogger.Info("UpdateWorkflow", $"Velopack update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup."); + AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup."); - return new UpdateDownloadResult(true, Path.Combine(incomingDir, VelopackReleasesFileName), null); + return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null); } /// @@ -224,11 +213,71 @@ public sealed class UpdateWorkflowService return false; } - // Velopack updates are identified by the releases feed path. - return pendingPath.EndsWith(VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase) + // Incoming payload updates are identified by files.json or incoming directory path. + return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase) || pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase); } + private static bool TryResolveDeltaAssets( + IReadOnlyList assets, + out GitHubReleaseAsset manifestAsset, + out GitHubReleaseAsset signatureAsset, + out GitHubReleaseAsset archiveAsset) + { + manifestAsset = default!; + signatureAsset = default!; + archiveAsset = default!; + + if (assets is null || assets.Count == 0) + { + return false; + } + + var platformSuffix = GetPlatformAssetSuffix(); + var platformManifest = $"files-{platformSuffix}.json"; + var platformSignature = $"files-{platformSuffix}.json.sig"; + var platformArchive = $"update-{platformSuffix}.zip"; + + var manifestCandidate = FindAsset(assets, platformManifest) ?? FindAsset(assets, SignedFileMapName); + var signatureCandidate = FindAsset(assets, platformSignature) ?? FindAsset(assets, SignedFileMapSignatureName); + var archiveCandidate = FindAsset(assets, platformArchive) ?? FindAsset(assets, UpdateArchiveName); + if (manifestCandidate is null || signatureCandidate is null || archiveCandidate is null) + { + return false; + } + + manifestAsset = manifestCandidate; + signatureAsset = signatureCandidate; + archiveAsset = archiveCandidate; + return true; + } + + private static GitHubReleaseAsset? FindAsset(IReadOnlyList assets, string name) + { + return assets.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + private static string GetPlatformAssetSuffix() + { + var os = OperatingSystem.IsWindows() + ? "windows" + : OperatingSystem.IsLinux() + ? "linux" + : OperatingSystem.IsMacOS() + ? "macos" + : "unknown"; + + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X86 => "x86", + Architecture.Arm => "arm", + Architecture.Arm64 => "arm64", + _ => "x64" + }; + + return $"{os}-{arch}"; + } + public UpdatePendingInfo? GetPendingUpdate() { var state = _settingsFacade.Update.Get(); diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 3088891..0104a82 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable; [ObservableProperty] - private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub; + private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc; [ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm; @@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _previewChannelText = string.Empty; + [ObservableProperty] + private string _pdcSourceText = string.Empty; + [ObservableProperty] private string _gitHubSourceText = string.Empty; @@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase public bool IsPreviewChannelSelected => string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase); + public bool IsPdcSourceSelected => + string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase); + public bool IsGitHubSourceSelected => string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase); @@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview; } + [RelayCommand] + private void SelectPdcSource() + { + SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc; + } + [RelayCommand] private void SelectGitHubSource() { @@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase DownloadProgressValue = 0; DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); UpdateStatus = isForce - ? L("settings.update.status_force_checking", "Force checking GitHub releases...") - : L("settings.update.status_checking", "Checking GitHub releases..."); + ? L("settings.update.status_force_checking", "Force checking update source...") + : L("settings.update.status_checking", "Checking update source..."); var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce); _lastCheckResult = result.Success ? result : null; @@ -2100,7 +2112,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads"); DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates."); ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update"); - ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison."); + ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison."); CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates"); DownloadButtonText = L("settings.update.download_install_button", "Download & Install"); InstallNowButtonText = L("settings.update.install_now_button", "Install Now"); @@ -2112,6 +2124,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase UpdateTypeLabel = L("settings.update.type_label", "Update Type"); StableChannelText = L("settings.update.channel_stable", "Stable"); PreviewChannelText = L("settings.update.channel_preview", "Preview"); + PdcSourceText = L("settings.update.source_pdc", "PDC"); GitHubSourceText = L("settings.update.source_github", "GitHub"); GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy"); ManualModeText = L("settings.update.mode_manual", "Manual Update"); @@ -2309,6 +2322,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase { return UpdateSettingsValues.NormalizeDownloadSource(value) switch { + UpdateSettingsValues.DownloadSourcePdc => L( + "settings.update.source_pdc_desc", + "Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."), UpdateSettingsValues.DownloadSourceGhProxy => L( "settings.update.source_ghproxy_desc", "Use the gh-proxy mirror when downloading GitHub release assets."), @@ -2360,6 +2376,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase { return [ + new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText), new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText), new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText) ]; diff --git a/phainon.yml b/phainon.yml new file mode 100644 index 0000000..f4aa909 --- /dev/null +++ b/phainon.yml @@ -0,0 +1,29 @@ +# Phainon Distribution Center (PDC) publish configuration +# This file is intentionally conservative: Launcher remains installer/rollback authority. +name: "LanMountainDesktop" + +components: + app: + allowDiffUpdate: true + root: "app-$(version)/" + includes: + - "**" + launcher: + root: "" + includes: + - "**" + excludes: + - "app-*/**" + - ".launcher/update/incoming/**" + - "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)/" diff --git a/scripts/Generate-DeltaPackage.ps1 b/scripts/Generate-DeltaPackage.ps1 index 34f4919..5bb7705 100644 --- a/scripts/Generate-DeltaPackage.ps1 +++ b/scripts/Generate-DeltaPackage.ps1 @@ -1,105 +1,152 @@ -# Generate-DeltaPackage.ps1 -# 生成增量更新包 (delta.zip + files.json) - param( - [Parameter(Mandatory=$true)] + [Parameter(Mandatory = $true)] [string]$PreviousVersion, - - [Parameter(Mandatory=$true)] + + [Parameter(Mandatory = $true)] [string]$CurrentVersion, - - [Parameter(Mandatory=$true)] + + [Parameter(Mandatory = $true)] [string]$PreviousDir, - - [Parameter(Mandatory=$true)] + + [Parameter(Mandatory = $true)] [string]$CurrentDir, - - [Parameter(Mandatory=$true)] + + [Parameter(Mandatory = $true)] [string]$OutputDir ) $ErrorActionPreference = "Stop" -Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan -Write-Host "从版本: $PreviousVersion" -Write-Host "到版本: $CurrentVersion" -Write-Host "上一版本目录: $PreviousDir" -Write-Host "当前版本目录: $CurrentDir" -Write-Host "输出目录: $OutputDir" -Write-Host "" +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem -# 确保输出目录存在 -New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +function Get-NormalizedRelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$RootDir, -# 计算文件 SHA256 -function Get-FileSha256 { - param([string]$Path) - $hash = Get-FileHash -Path $Path -Algorithm SHA256 - return $hash.Hash.ToLower() + [Parameter(Mandatory = $true)] + [string]$FullPath + ) + + $root = [System.IO.Path]::GetFullPath($RootDir) + $path = [System.IO.Path]::GetFullPath($FullPath) + + if (-not $root.EndsWith([System.IO.Path]::DirectorySeparatorChar.ToString()) -and + -not $root.EndsWith([System.IO.Path]::AltDirectorySeparatorChar.ToString())) { + $root += [System.IO.Path]::DirectorySeparatorChar + } + + $rootUri = [System.Uri]$root + $pathUri = [System.Uri]$path + $relative = [System.Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()) + + return $relative.Replace('\', '/') +} + +function Get-FileSha256Hex { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } -# 获取目录中所有文件的相对路径和哈希 function Get-FileManifest { - param([string]$RootDir) - + param( + [Parameter(Mandatory = $true)] + [string]$RootDir + ) + + if (-not (Test-Path -LiteralPath $RootDir)) { + throw "Directory does not exist: $RootDir" + } + + $resolvedRoot = (Resolve-Path -LiteralPath $RootDir).Path $manifest = @{} - $files = Get-ChildItem -Path $RootDir -Recurse -File - + $files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File + foreach ($file in $files) { - $relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/') - $relativePath = $relativePath.Replace('\', '/') - - $manifest[$relativePath] = @{ + $relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName + $manifest[$relativePath] = [ordered]@{ Path = $relativePath - Sha256 = Get-FileSha256 -Path $file.FullName - Size = $file.Length + Sha256 = Get-FileSha256Hex -Path $file.FullName + Size = [long]$file.Length } } - + return $manifest } -Write-Host "扫描上一版本文件..." -ForegroundColor Yellow -Write-Host " 目录: $PreviousDir" -ForegroundColor Gray -if (-not (Test-Path $PreviousDir)) { - throw "Previous directory does not exist: $PreviousDir" +function New-DeltaArchive { + param( + [Parameter(Mandatory = $true)] + [string]$ZipPath, + + [Parameter(Mandatory = $true)] + [string]$CurrentRoot, + + [Parameter(Mandatory = $true)] + [object[]]$ChangedFiles + ) + + if (Test-Path -LiteralPath $ZipPath) { + Remove-Item -LiteralPath $ZipPath -Force + } + + $zip = [System.IO.Compression.ZipFile]::Open($ZipPath, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in $ChangedFiles) { + $sourcePath = Join-Path $CurrentRoot $file.Path + if (-not (Test-Path -LiteralPath $sourcePath)) { + throw "Changed file was not found while building archive: $sourcePath" + } + + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $zip, + $sourcePath, + $file.Path, + [System.IO.Compression.CompressionLevel]::Optimal + ) | Out-Null + } + } + finally { + $zip.Dispose() + } } + +Write-Host "Generating incremental package..." +Write-Host "From: $PreviousVersion" +Write-Host "To: $CurrentVersion" +Write-Host "Prev: $PreviousDir" +Write-Host "Curr: $CurrentDir" +Write-Host "Out: $OutputDir" + +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + $previousManifest = Get-FileManifest -RootDir $PreviousDir -Write-Host " 找到 $($previousManifest.Count) 个文件" -ForegroundColor Gray - -Write-Host "扫描当前版本文件..." -ForegroundColor Yellow -Write-Host " 目录: $CurrentDir" -ForegroundColor Gray -if (-not (Test-Path $CurrentDir)) { - throw "Current directory does not exist: $CurrentDir" -} $currentManifest = Get-FileManifest -RootDir $CurrentDir -Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray -# 分析文件变更 $changedFiles = @() $reusedFiles = @() $deletedFiles = @() -Write-Host "分析文件变更..." -ForegroundColor Yellow - -# 检查新增和修改的文件 -foreach ($path in $currentManifest.Keys) { +foreach ($path in ($currentManifest.Keys | Sort-Object)) { $currentFile = $currentManifest[$path] - + if ($previousManifest.ContainsKey($path)) { $previousFile = $previousManifest[$path] - if ($currentFile.Sha256 -eq $previousFile.Sha256) { - # 文件未变更,可以复用 - $reusedFiles += @{ + $reusedFiles += [ordered]@{ Path = $path Action = "reuse" Sha256 = $currentFile.Sha256 Size = $currentFile.Size } - } else { - # 文件已修改 - $changedFiles += @{ + } + else { + $changedFiles += [ordered]@{ Path = $path Action = "replace" Sha256 = $currentFile.Sha256 @@ -107,9 +154,9 @@ foreach ($path in $currentManifest.Keys) { ArchivePath = $path } } - } else { - # 新增文件 - $changedFiles += @{ + } + else { + $changedFiles += [ordered]@{ Path = $path Action = "add" Sha256 = $currentFile.Sha256 @@ -119,104 +166,51 @@ foreach ($path in $currentManifest.Keys) { } } -# 检查删除的文件 -foreach ($path in $previousManifest.Keys) { +foreach ($path in ($previousManifest.Keys | Sort-Object)) { if (-not $currentManifest.ContainsKey($path)) { - $deletedFiles += @{ + $deletedFiles += [ordered]@{ Path = $path Action = "delete" } } } -Write-Host "变更统计:" -ForegroundColor Green -Write-Host " 新增/修改: $($changedFiles.Count) 个文件" -Write-Host " 复用: $($reusedFiles.Count) 个文件" -Write-Host " 删除: $($deletedFiles.Count) 个文件" -Write-Host "" +Write-Host "Changed: $($changedFiles.Count)" +Write-Host "Reused: $($reusedFiles.Count)" +Write-Host "Deleted: $($deletedFiles.Count)" -# 显示前10个变更的文件(用于调试) -if ($changedFiles.Count -gt 0) { - Write-Host "变更的文件示例:" -ForegroundColor Cyan - $changedFiles | Select-Object -First 10 | ForEach-Object { - Write-Host " [$($_.Action)] $($_.Path)" -ForegroundColor Gray - } - if ($changedFiles.Count -gt 10) { - Write-Host " ... 还有 $($changedFiles.Count - 10) 个文件" -ForegroundColor Gray - } - Write-Host "" -} - -# 创建临时目录用于打包 -$tempDir = Join-Path $OutputDir "temp_delta" -if (Test-Path $tempDir) { - Remove-Item -Path $tempDir -Recurse -Force -} -New-Item -ItemType Directory -Force -Path $tempDir | Out-Null - -# 复制变更的文件到临时目录 -Write-Host "复制变更文件..." -ForegroundColor Yellow -foreach ($file in $changedFiles) { - $sourcePath = Join-Path $CurrentDir $file.Path - $destPath = Join-Path $tempDir $file.Path - $destDir = Split-Path -Parent $destPath - - if (-not (Test-Path $destDir)) { - New-Item -ItemType Directory -Force -Path $destDir | Out-Null - } - - Copy-Item -Path $sourcePath -Destination $destPath -Force -} - -# 创建 update.zip (Launcher 期望的文件名) +$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path $updateZipPath = Join-Path $OutputDir "update.zip" -Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow +New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles -if (Test-Path $updateZipPath) { - Remove-Item -Path $updateZipPath -Force -} +$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion) +Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force -Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal - -# 同时创建带版本号的副本(用于发布到 GitHub Release) -$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip" -Write-Host "创建带版本号的副本: $deltaZipPath" -ForegroundColor Yellow -if (Test-Path $deltaZipPath) { - Remove-Item -Path $deltaZipPath -Force -} -Copy-Item -Path $updateZipPath -Destination $deltaZipPath -Force - -# 清理临时目录 -Remove-Item -Path $tempDir -Recurse -Force - -# 生成 files.json (Launcher 期望的文件名) -$filesJson = @{ +$allEntries = @($changedFiles + $reusedFiles + $deletedFiles) +$filesJson = [ordered]@{ FromVersion = $PreviousVersion ToVersion = $CurrentVersion - GeneratedAt = (Get-Date).ToUniversalTime().ToString("o") - Files = @($changedFiles + $reusedFiles + $deletedFiles) + GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o") + Files = $allEntries } +$jsonText = $filesJson | ConvertTo-Json -Depth 10 +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) + $filesJsonPath = Join-Path $OutputDir "files.json" -Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow +[System.IO.File]::WriteAllText($filesJsonPath, $jsonText, $utf8NoBom) -$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8 +$versionedFilesJsonPath = Join-Path $OutputDir ("files-{0}.json" -f $CurrentVersion) +Copy-Item -LiteralPath $filesJsonPath -Destination $versionedFilesJsonPath -Force -# 同时创建带版本号的副本(用于发布到 GitHub Release) -$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json" -Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow -Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force - -# 计算增量包大小 -$updateSize = (Get-Item $updateZipPath).Length -$updateSizeMB = [math]::Round($updateSize / 1MB, 2) +$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length +$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2) Write-Host "" -Write-Host "=== 完成 ===" -ForegroundColor Green -Write-Host "增量包大小: $updateSizeMB MB" -Write-Host "输出文件 (Launcher 使用):" -Write-Host " - $updateZipPath" -Write-Host " - $filesJsonPath" -Write-Host "输出文件 (GitHub Release 发布):" -Write-Host " - $deltaZipPath" -Write-Host " - $versionedFilesJsonPath" +Write-Host "Done." +Write-Host "update.zip size: $updateSizeMb MB" +Write-Host "Generated:" +Write-Host " $updateZipPath" +Write-Host " $filesJsonPath" +Write-Host " $deltaZipPath" +Write-Host " $versionedFilesJsonPath" diff --git a/scripts/Sign-FileMap.ps1 b/scripts/Sign-FileMap.ps1 index 4747fb7..fe92155 100644 --- a/scripts/Sign-FileMap.ps1 +++ b/scripts/Sign-FileMap.ps1 @@ -1,65 +1,56 @@ -# Sign-FileMap.ps1 -# 对 files.json 进行 RSA 签名 - param( - [Parameter(Mandatory=$true)] + [Parameter(Mandatory = $true)] [string]$FilesJsonPath, - - [Parameter(Mandatory=$true)] + + [Parameter(Mandatory = $true)] [string]$PrivateKeyPath, - - [Parameter(Mandatory=$false)] + + [Parameter(Mandatory = $false)] [string]$OutputPath ) $ErrorActionPreference = "Stop" -Write-Host "=== 签名文件清单 ===" -ForegroundColor Cyan -Write-Host "文件清单: $FilesJsonPath" -Write-Host "私钥: $PrivateKeyPath" -Write-Host "" - -# 检查文件是否存在 -if (-not (Test-Path $FilesJsonPath)) { - Write-Error "文件清单不存在: $FilesJsonPath" - exit 1 +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "Sign-FileMap.ps1 requires PowerShell 7 or newer." } -if (-not (Test-Path $PrivateKeyPath)) { - Write-Error "私钥文件不存在: $PrivateKeyPath" - exit 1 +if (-not (Test-Path -LiteralPath $FilesJsonPath)) { + throw "Manifest file not found: $FilesJsonPath" +} + +if (-not (Test-Path -LiteralPath $PrivateKeyPath)) { + throw "Private key file not found: $PrivateKeyPath" } -# 确定输出路径 if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = "$FilesJsonPath.sig" } -# 读取文件内容 -$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath) +$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path +$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath) -# 读取私钥 -$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw - -# 使用 .NET 进行 RSA 签名 -Add-Type -AssemblyName System.Security.Cryptography +$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw +if ([string]::IsNullOrWhiteSpace($privateKeyPem)) { + throw "Private key PEM is empty: $PrivateKeyPath" +} $rsa = [System.Security.Cryptography.RSA]::Create() -$rsa.ImportFromPem($privateKeyPem) +try { + $rsa.ImportFromPem($privateKeyPem) + $signatureBytes = $rsa.SignData( + $manifestBytes, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) +} +finally { + $rsa.Dispose() +} -# 生成签名 -$signature = $rsa.SignData( - $jsonBytes, - [System.Security.Cryptography.HashAlgorithmName]::SHA256, - [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 -) +$signatureBase64 = [Convert]::ToBase64String($signatureBytes) +[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII) -# 转换为 Base64 -$signatureBase64 = [Convert]::ToBase64String($signature) - -# 写入签名文件 -Set-Content -Path $OutputPath -Value $signatureBase64 -Encoding ASCII - -Write-Host "=== 完成 ===" -ForegroundColor Green -Write-Host "签名文件: $OutputPath" -Write-Host "签名长度: $($signature.Length) 字节" +Write-Host "Signed manifest file." +Write-Host "Manifest: $FilesJsonPath" +Write-Host "Signature: $OutputPath"