mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
chore: migrate release pipeline to signed filemap and wire rainyun s3
This commit is contained in:
262
.github/workflows/release.yml
vendored
262
.github/workflows/release.yml
vendored
@@ -20,7 +20,6 @@ env:
|
|||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
Solution_Name: LanMountainDesktop.slnx
|
Solution_Name: LanMountainDesktop.slnx
|
||||||
DOTNET_gcServer: 1
|
DOTNET_gcServer: 1
|
||||||
ENABLE_LEGACY_DELTA_FALLBACK: 'false'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
@@ -318,56 +317,20 @@ jobs:
|
|||||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Install vpk
|
- name: Build Signed FileMap Update Package
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
if: matrix.self_contained == true
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = "Stop"
|
$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 }}"
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
$arch = "${{ matrix.arch }}"
|
$arch = "${{ matrix.arch }}"
|
||||||
|
$platform = "windows-$arch"
|
||||||
$publishDir = "publish/windows-$arch"
|
$publishDir = "publish/windows-$arch"
|
||||||
$appDir = "app-$version"
|
$appDir = "app-$version"
|
||||||
$currentAppPath = Join-Path $publishDir $appDir
|
$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)) {
|
if (-not (Test-Path $currentAppPath)) {
|
||||||
Write-Error "Expected app directory not found: $currentAppPath"
|
Write-Error "Expected app directory not found: $currentAppPath"
|
||||||
@@ -375,54 +338,65 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||||
vpk pack `
|
& $generateScript `
|
||||||
--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 `
|
|
||||||
-PreviousVersion "0.0.0" `
|
-PreviousVersion "0.0.0" `
|
||||||
-CurrentVersion $version `
|
-CurrentVersion $version `
|
||||||
-PreviousDir $currentAppPath `
|
-PreviousDir $currentAppPath `
|
||||||
-CurrentDir $currentAppPath `
|
-CurrentDir $currentAppPath `
|
||||||
-OutputDir $outputDir
|
-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
|
shell: pwsh
|
||||||
|
|
||||||
- name: Upload Velopack Package
|
- name: Upload Signed FileMap Update Package
|
||||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
if: matrix.self_contained == true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-velopack-windows-x64
|
name: release-update-windows-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
velopack-output/*.nupkg
|
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json
|
||||||
velopack-output/releases.win.json
|
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json.sig
|
||||||
velopack-output/assets.win.json
|
delta-output/windows-${{ matrix.arch }}/update-windows-${{ matrix.arch }}.zip
|
||||||
velopack-output/RELEASES
|
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 90
|
retention-days: 90
|
||||||
- name: Upload Installer
|
- name: Upload Installer
|
||||||
@@ -630,6 +604,86 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -832,8 +886,8 @@ jobs:
|
|||||||
mkdir -p release-files
|
mkdir -p release-files
|
||||||
# Copy installers and packages
|
# Copy installers and packages
|
||||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
||||||
# Copy Velopack release feed and update packages
|
# Copy signed file-map incremental update assets
|
||||||
find artifacts -type f \( -name "releases.win.json" -o -name "assets.win.json" -o -name "RELEASES" -o -name "*.nupkg" \) -exec cp -v {} release-files/ \;
|
find artifacts -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
|
||||||
echo ""
|
echo ""
|
||||||
echo "Files ready for release:"
|
echo "Files ready for release:"
|
||||||
ls -lh release-files/ || echo "No files found in release-files"
|
ls -lh release-files/ || echo "No files found in release-files"
|
||||||
@@ -846,6 +900,38 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Create Release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
@@ -867,12 +953,12 @@ jobs:
|
|||||||
|
|
||||||
Installation: Double-click the .exe file and follow the wizard.
|
Installation: Double-click the .exe file and follow the wizard.
|
||||||
|
|
||||||
### Incremental Update (Windows x64)
|
### Incremental Update Assets
|
||||||
- **releases.win.json** - Velopack release feed consumed by the launcher update flow
|
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
|
||||||
- **LanMountainDesktop-<version>-full.nupkg** - full package
|
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
|
||||||
- **LanMountainDesktop-<version>-delta.nupkg** - delta package (when available)
|
- **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
|
### Linux
|
||||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
||||||
|
|||||||
10
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
10
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
@@ -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.
|
||||||
30
.trae/specs/pdc-incremental-migration/spec.md
Normal file
30
.trae/specs/pdc-incremental-migration/spec.md
Normal file
@@ -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.
|
||||||
12
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
12
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
@@ -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.
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# Checklist
|
# Checklist (Deprecated)
|
||||||
|
|
||||||
- [x] `releases.win.json` recognized by host update download flow.
|
- [x] Spec marked as deprecated.
|
||||||
- [x] Launcher pending update check supports VeloPack payload.
|
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
|
||||||
- [x] Launcher apply uses deployment markers (`.current/.partial/.destroy`) unchanged.
|
- [x] No release workflow dependency remains on VeloPack.
|
||||||
- [x] Legacy script path retained as emergency fallback.
|
|
||||||
- [ ] Staging verification report attached.
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
# VeloPack Update Integration
|
# VeloPack Update Integration (Deprecated)
|
||||||
|
|
||||||
## Goal
|
## Status
|
||||||
Switch incremental package generation and release assets to VeloPack native outputs while keeping Launcher as the update installer and rollback authority.
|
|
||||||
|
|
||||||
## Requirements
|
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Acceptance
|
## Deprecation Reason
|
||||||
- Build and quality workflows pass after migration changes.
|
|
||||||
- Release workflow publishes VeloPack assets.
|
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||||
- Launcher `update apply` succeeds with VeloPack full package payload.
|
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||||
- Manual rollback still works after a VeloPack-based update.
|
- 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.
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
# Tasks
|
# Tasks (Deprecated)
|
||||||
|
|
||||||
- [x] Fix Launcher `LoadingDetailsWindow.axaml` compile regression.
|
- [x] Mark VeloPack integration spec as deprecated.
|
||||||
- [x] Add VeloPack feed/package model support in Launcher update engine.
|
- [x] Remove VeloPack runtime branches from launcher/host update path.
|
||||||
- [x] Keep legacy delta flow behind disabled fallback switch.
|
- [x] Remove VeloPack release workflow packaging steps.
|
||||||
- [x] Migrate release workflow packaging assets to VeloPack outputs.
|
- [ ] Keep archive for historical context only (no new implementation tasks here).
|
||||||
- [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.
|
|
||||||
|
|||||||
@@ -20,7 +20,4 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(GitHubRelease))]
|
[JsonSerializable(typeof(GitHubRelease))]
|
||||||
[JsonSerializable(typeof(GitHubAsset))]
|
[JsonSerializable(typeof(GitHubAsset))]
|
||||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||||
[JsonSerializable(typeof(VelopackReleaseFeed))]
|
|
||||||
[JsonSerializable(typeof(VelopackReleaseAsset))]
|
|
||||||
[JsonSerializable(typeof(List<VelopackReleaseAsset>))]
|
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ public sealed class ReleaseInfo
|
|||||||
public required DateTime PublishedAt { get; init; }
|
public required DateTime PublishedAt { get; init; }
|
||||||
public required List<ReleaseAsset> Assets { get; init; }
|
public required List<ReleaseAsset> Assets { get; init; }
|
||||||
public string? Body { get; init; }
|
public string? Body { get; init; }
|
||||||
public string? VelopackFeedUrl { get; init; }
|
|
||||||
public string? VelopackLegacyReleasesUrl { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace LanMountainDesktop.Launcher.Models;
|
|
||||||
|
|
||||||
internal sealed class VelopackReleaseFeed
|
|
||||||
{
|
|
||||||
public List<VelopackReleaseAsset> 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; }
|
|
||||||
}
|
|
||||||
@@ -104,26 +104,6 @@ internal static class Commands
|
|||||||
|
|
||||||
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||||
{
|
{
|
||||||
var releasesUrl = context.GetOption("releases-url");
|
|
||||||
if (!string.IsNullOrWhiteSpace(releasesUrl))
|
|
||||||
{
|
|
||||||
var packageUrls = new List<string>();
|
|
||||||
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(
|
return await updateEngine.DownloadAsync(
|
||||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||||
|
|||||||
@@ -104,11 +104,7 @@ internal sealed class UpdateCheckService
|
|||||||
Name = a.Name ?? "",
|
Name = a.Name ?? "",
|
||||||
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
||||||
Size = a.Size
|
Size = a.Size
|
||||||
}).ToList() ?? [],
|
}).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() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ internal sealed class UpdateEngineService
|
|||||||
private const string SignedFileMapName = "files.json";
|
private const string SignedFileMapName = "files.json";
|
||||||
private const string SignatureFileName = "files.json.sig";
|
private const string SignatureFileName = "files.json.sig";
|
||||||
private const string ArchiveFileName = "update.zip";
|
private const string ArchiveFileName = "update.zip";
|
||||||
private const string VelopackReleasesFileName = "releases.win.json";
|
|
||||||
private const string PublicKeyFileName = "public-key.pem";
|
private const string PublicKeyFileName = "public-key.pem";
|
||||||
|
|
||||||
private readonly DeploymentLocator _deploymentLocator;
|
private readonly DeploymentLocator _deploymentLocator;
|
||||||
@@ -34,16 +33,6 @@ internal sealed class UpdateEngineService
|
|||||||
|
|
||||||
public LauncherResult CheckPendingUpdate()
|
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 fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||||
@@ -82,47 +71,6 @@ internal sealed class UpdateEngineService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LauncherResult> DownloadVelopackAsync(
|
|
||||||
string releasesJsonUrl,
|
|
||||||
IReadOnlyList<string> 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<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken)
|
public async Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(_incomingRoot);
|
Directory.CreateDirectory(_incomingRoot);
|
||||||
@@ -167,12 +115,6 @@ internal sealed class UpdateEngineService
|
|||||||
Directory.CreateDirectory(_incomingRoot);
|
Directory.CreateDirectory(_incomingRoot);
|
||||||
Directory.CreateDirectory(_snapshotsRoot);
|
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 fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||||
@@ -631,8 +573,7 @@ internal sealed class UpdateEngineService
|
|||||||
{
|
{
|
||||||
Path.Combine(_incomingRoot, SignedFileMapName),
|
Path.Combine(_incomingRoot, SignedFileMapName),
|
||||||
Path.Combine(_incomingRoot, SignatureFileName),
|
Path.Combine(_incomingRoot, SignatureFileName),
|
||||||
Path.Combine(_incomingRoot, ArchiveFileName),
|
Path.Combine(_incomingRoot, ArchiveFileName)
|
||||||
Path.Combine(_incomingRoot, VelopackReleasesFileName)
|
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
try
|
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)
|
private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath)
|
||||||
@@ -724,307 +654,6 @@ internal sealed class UpdateEngineService
|
|||||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
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<LauncherResult> 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)
|
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
|
||||||
{
|
{
|
||||||
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
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;
|
public int UpdateDownloadThreads { get; set; } = 4;
|
||||||
|
|
||||||
|
|||||||
464
LanMountainDesktop/Services/PdcReleaseUpdateService.cs
Normal file
464
LanMountainDesktop/Services/PdcReleaseUpdateService.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort PDC client that maps PDC responses to the existing update result model.
|
||||||
|
/// This keeps launcher update contracts stable while allowing a gradual migration.
|
||||||
|
/// </summary>
|
||||||
|
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<UpdateCheckResult> CheckForUpdatesAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
bool includePrerelease,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
bool includePrerelease,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UpdateCheckResult> 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<JsonElement> 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<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
|
||||||
|
{
|
||||||
|
var assets = new List<GitHubReleaseAsset>();
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -751,7 +751,8 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
|
|||||||
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
|
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly ISettingsService _settingsService;
|
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)
|
public UpdateSettingsService(ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
bool includePrerelease,
|
bool includePrerelease,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||||
@@ -838,7 +839,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
bool includePrerelease,
|
bool includePrerelease,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
@@ -849,7 +850,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
IProgress<double>? progress = null,
|
IProgress<double>? progress = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _releaseUpdateService.DownloadAssetAsync(
|
return _githubReleaseUpdateService.DownloadAssetAsync(
|
||||||
asset,
|
asset,
|
||||||
destinationFilePath,
|
destinationFilePath,
|
||||||
downloadSource,
|
downloadSource,
|
||||||
@@ -866,7 +867,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
IProgress<double>? progress = null,
|
IProgress<double>? progress = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return _releaseUpdateService.RedownloadAssetAsync(
|
return _githubReleaseUpdateService.RedownloadAssetAsync(
|
||||||
asset,
|
asset,
|
||||||
destinationFilePath,
|
destinationFilePath,
|
||||||
downloadSource,
|
downloadSource,
|
||||||
@@ -877,7 +878,36 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_releaseUpdateService.Dispose();
|
_githubReleaseUpdateService.Dispose();
|
||||||
|
_pdcReleaseUpdateService.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UpdateCheckResult> 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public static class UpdateSettingsValues
|
|||||||
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
||||||
public const string ModeSilentOnExit = "silent_on_exit";
|
public const string ModeSilentOnExit = "silent_on_exit";
|
||||||
|
|
||||||
|
public const string DownloadSourcePdc = "pdc";
|
||||||
public const string DownloadSourceGitHub = "github";
|
public const string DownloadSourceGitHub = "github";
|
||||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||||
|
|
||||||
@@ -51,9 +52,23 @@ public static class UpdateSettingsValues
|
|||||||
|
|
||||||
public static string NormalizeDownloadSource(string? value)
|
public static string NormalizeDownloadSource(string? value)
|
||||||
{
|
{
|
||||||
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
||||||
? DownloadSourceGhProxy
|
{
|
||||||
: DownloadSourceGitHub;
|
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)
|
public static int NormalizeDownloadThreads(int value)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
@@ -52,7 +53,9 @@ public sealed class UpdateWorkflowService
|
|||||||
private const string LauncherDirectoryName = ".launcher";
|
private const string LauncherDirectoryName = ".launcher";
|
||||||
private const string UpdateDirectoryName = "update";
|
private const string UpdateDirectoryName = "update";
|
||||||
private const string IncomingDirectoryName = "incoming";
|
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)
|
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||||
{
|
{
|
||||||
@@ -79,7 +82,7 @@ public sealed class UpdateWorkflowService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
||||||
{
|
{
|
||||||
@@ -88,13 +91,11 @@ public sealed class UpdateWorkflowService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasFeed = release.Assets.Any(a => string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase));
|
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
|
||||||
var hasFull = release.Assets.Any(a => a.Name.EndsWith("-full.nupkg", StringComparison.OrdinalIgnoreCase));
|
|
||||||
return hasFeed && hasFull;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads Velopack release feed and package files to the Launcher's incoming directory.
|
/// Downloads signed file-map assets to the Launcher's incoming directory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
||||||
UpdateCheckResult checkResult,
|
UpdateCheckResult checkResult,
|
||||||
@@ -108,11 +109,9 @@ public sealed class UpdateWorkflowService
|
|||||||
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var releasesFeedAsset = checkResult.Release.Assets.FirstOrDefault(a =>
|
if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
|
||||||
string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (releasesFeedAsset is null)
|
|
||||||
{
|
{
|
||||||
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();
|
var incomingDir = GetLauncherIncomingDirectory();
|
||||||
@@ -130,29 +129,19 @@ public sealed class UpdateWorkflowService
|
|||||||
var downloadSource = state.UpdateDownloadSource;
|
var downloadSource = state.UpdateDownloadSource;
|
||||||
var downloadThreads = state.UpdateDownloadThreads;
|
var downloadThreads = state.UpdateDownloadThreads;
|
||||||
|
|
||||||
var latestVersionText = checkResult.LatestVersionText.Trim();
|
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
return new UpdateDownloadResult(false, null, "No Velopack nupkg asset found for the target version.");
|
(manifestAsset, SignedFileMapName),
|
||||||
}
|
(signatureAsset, SignedFileMapSignatureName),
|
||||||
|
(archiveAsset, UpdateArchiveName)
|
||||||
var requiredAssets = new List<GitHubReleaseAsset> { releasesFeedAsset };
|
};
|
||||||
requiredAssets.AddRange(targetPackages);
|
|
||||||
|
|
||||||
var totalAssets = requiredAssets.Count;
|
var totalAssets = requiredAssets.Count;
|
||||||
var completedAssets = 0;
|
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
|
// Skip if already downloaded and file exists
|
||||||
if (File.Exists(destinationPath))
|
if (File.Exists(destinationPath))
|
||||||
@@ -160,7 +149,7 @@ public sealed class UpdateWorkflowService
|
|||||||
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
||||||
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
|
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++;
|
completedAssets++;
|
||||||
progress?.Report((double)completedAssets / totalAssets);
|
progress?.Report((double)completedAssets / totalAssets);
|
||||||
continue;
|
continue;
|
||||||
@@ -184,21 +173,21 @@ public sealed class UpdateWorkflowService
|
|||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
// Clean up partially downloaded files
|
// 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 { }
|
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++;
|
completedAssets++;
|
||||||
progress?.Report((double)completedAssets / totalAssets);
|
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
|
SaveState(state with
|
||||||
{
|
{
|
||||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, VelopackReleasesFileName),
|
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
|
||||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||||
? null
|
? null
|
||||||
@@ -207,9 +196,9 @@ public sealed class UpdateWorkflowService
|
|||||||
PendingUpdateSha256 = null
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -224,11 +213,71 @@ public sealed class UpdateWorkflowService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Velopack updates are identified by the releases feed path.
|
// Incoming payload updates are identified by files.json or incoming directory path.
|
||||||
return pendingPath.EndsWith(VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase)
|
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|
||||||
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryResolveDeltaAssets(
|
||||||
|
IReadOnlyList<GitHubReleaseAsset> 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<GitHubReleaseAsset> 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()
|
public UpdatePendingInfo? GetPendingUpdate()
|
||||||
{
|
{
|
||||||
var state = _settingsFacade.Update.Get();
|
var state = _settingsFacade.Update.Get();
|
||||||
|
|||||||
@@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
|
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||||
@@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _previewChannelText = string.Empty;
|
private string _previewChannelText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _pdcSourceText = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _gitHubSourceText = string.Empty;
|
private string _gitHubSourceText = string.Empty;
|
||||||
|
|
||||||
@@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
public bool IsPreviewChannelSelected =>
|
public bool IsPreviewChannelSelected =>
|
||||||
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public bool IsPdcSourceSelected =>
|
||||||
|
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsGitHubSourceSelected =>
|
public bool IsGitHubSourceSelected =>
|
||||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SelectPdcSource()
|
||||||
|
{
|
||||||
|
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectGitHubSource()
|
private void SelectGitHubSource()
|
||||||
{
|
{
|
||||||
@@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
DownloadProgressValue = 0;
|
DownloadProgressValue = 0;
|
||||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||||
UpdateStatus = isForce
|
UpdateStatus = isForce
|
||||||
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
|
? L("settings.update.status_force_checking", "Force checking update source...")
|
||||||
: L("settings.update.status_checking", "Checking GitHub releases...");
|
: L("settings.update.status_checking", "Checking update source...");
|
||||||
|
|
||||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
||||||
_lastCheckResult = result.Success ? result : null;
|
_lastCheckResult = result.Success ? result : null;
|
||||||
@@ -2100,7 +2112,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
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.");
|
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");
|
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");
|
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
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");
|
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
|
||||||
StableChannelText = L("settings.update.channel_stable", "Stable");
|
StableChannelText = L("settings.update.channel_stable", "Stable");
|
||||||
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
||||||
|
PdcSourceText = L("settings.update.source_pdc", "PDC");
|
||||||
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
||||||
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
||||||
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
||||||
@@ -2309,6 +2322,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
|
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(
|
UpdateSettingsValues.DownloadSourceGhProxy => L(
|
||||||
"settings.update.source_ghproxy_desc",
|
"settings.update.source_ghproxy_desc",
|
||||||
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
||||||
@@ -2360,6 +2376,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
|
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
|
||||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
||||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
||||||
];
|
];
|
||||||
|
|||||||
29
phainon.yml
Normal file
29
phainon.yml
Normal file
@@ -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)/"
|
||||||
@@ -1,105 +1,152 @@
|
|||||||
# Generate-DeltaPackage.ps1
|
|
||||||
# 生成增量更新包 (delta.zip + files.json)
|
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$PreviousVersion,
|
[string]$PreviousVersion,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$CurrentVersion,
|
[string]$CurrentVersion,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$PreviousDir,
|
[string]$PreviousDir,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$CurrentDir,
|
[string]$CurrentDir,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$OutputDir
|
[string]$OutputDir
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
|
Add-Type -AssemblyName System.IO.Compression
|
||||||
Write-Host "从版本: $PreviousVersion"
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
Write-Host "到版本: $CurrentVersion"
|
|
||||||
Write-Host "上一版本目录: $PreviousDir"
|
|
||||||
Write-Host "当前版本目录: $CurrentDir"
|
|
||||||
Write-Host "输出目录: $OutputDir"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 确保输出目录存在
|
function Get-NormalizedRelativePath {
|
||||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$RootDir,
|
||||||
|
|
||||||
# 计算文件 SHA256
|
[Parameter(Mandatory = $true)]
|
||||||
function Get-FileSha256 {
|
[string]$FullPath
|
||||||
param([string]$Path)
|
)
|
||||||
$hash = Get-FileHash -Path $Path -Algorithm SHA256
|
|
||||||
return $hash.Hash.ToLower()
|
$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 {
|
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 = @{}
|
$manifest = @{}
|
||||||
$files = Get-ChildItem -Path $RootDir -Recurse -File
|
$files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File
|
||||||
|
|
||||||
foreach ($file in $files) {
|
foreach ($file in $files) {
|
||||||
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
|
$relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName
|
||||||
$relativePath = $relativePath.Replace('\', '/')
|
$manifest[$relativePath] = [ordered]@{
|
||||||
|
|
||||||
$manifest[$relativePath] = @{
|
|
||||||
Path = $relativePath
|
Path = $relativePath
|
||||||
Sha256 = Get-FileSha256 -Path $file.FullName
|
Sha256 = Get-FileSha256Hex -Path $file.FullName
|
||||||
Size = $file.Length
|
Size = [long]$file.Length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $manifest
|
return $manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
|
function New-DeltaArchive {
|
||||||
Write-Host " 目录: $PreviousDir" -ForegroundColor Gray
|
param(
|
||||||
if (-not (Test-Path $PreviousDir)) {
|
[Parameter(Mandatory = $true)]
|
||||||
throw "Previous directory does not exist: $PreviousDir"
|
[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
|
$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
|
$currentManifest = Get-FileManifest -RootDir $CurrentDir
|
||||||
Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# 分析文件变更
|
|
||||||
$changedFiles = @()
|
$changedFiles = @()
|
||||||
$reusedFiles = @()
|
$reusedFiles = @()
|
||||||
$deletedFiles = @()
|
$deletedFiles = @()
|
||||||
|
|
||||||
Write-Host "分析文件变更..." -ForegroundColor Yellow
|
foreach ($path in ($currentManifest.Keys | Sort-Object)) {
|
||||||
|
|
||||||
# 检查新增和修改的文件
|
|
||||||
foreach ($path in $currentManifest.Keys) {
|
|
||||||
$currentFile = $currentManifest[$path]
|
$currentFile = $currentManifest[$path]
|
||||||
|
|
||||||
if ($previousManifest.ContainsKey($path)) {
|
if ($previousManifest.ContainsKey($path)) {
|
||||||
$previousFile = $previousManifest[$path]
|
$previousFile = $previousManifest[$path]
|
||||||
|
|
||||||
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
|
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
|
||||||
# 文件未变更,可以复用
|
$reusedFiles += [ordered]@{
|
||||||
$reusedFiles += @{
|
|
||||||
Path = $path
|
Path = $path
|
||||||
Action = "reuse"
|
Action = "reuse"
|
||||||
Sha256 = $currentFile.Sha256
|
Sha256 = $currentFile.Sha256
|
||||||
Size = $currentFile.Size
|
Size = $currentFile.Size
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
# 文件已修改
|
else {
|
||||||
$changedFiles += @{
|
$changedFiles += [ordered]@{
|
||||||
Path = $path
|
Path = $path
|
||||||
Action = "replace"
|
Action = "replace"
|
||||||
Sha256 = $currentFile.Sha256
|
Sha256 = $currentFile.Sha256
|
||||||
@@ -107,9 +154,9 @@ foreach ($path in $currentManifest.Keys) {
|
|||||||
ArchivePath = $path
|
ArchivePath = $path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
# 新增文件
|
else {
|
||||||
$changedFiles += @{
|
$changedFiles += [ordered]@{
|
||||||
Path = $path
|
Path = $path
|
||||||
Action = "add"
|
Action = "add"
|
||||||
Sha256 = $currentFile.Sha256
|
Sha256 = $currentFile.Sha256
|
||||||
@@ -119,104 +166,51 @@ foreach ($path in $currentManifest.Keys) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查删除的文件
|
foreach ($path in ($previousManifest.Keys | Sort-Object)) {
|
||||||
foreach ($path in $previousManifest.Keys) {
|
|
||||||
if (-not $currentManifest.ContainsKey($path)) {
|
if (-not $currentManifest.ContainsKey($path)) {
|
||||||
$deletedFiles += @{
|
$deletedFiles += [ordered]@{
|
||||||
Path = $path
|
Path = $path
|
||||||
Action = "delete"
|
Action = "delete"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "变更统计:" -ForegroundColor Green
|
Write-Host "Changed: $($changedFiles.Count)"
|
||||||
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
|
Write-Host "Reused: $($reusedFiles.Count)"
|
||||||
Write-Host " 复用: $($reusedFiles.Count) 个文件"
|
Write-Host "Deleted: $($deletedFiles.Count)"
|
||||||
Write-Host " 删除: $($deletedFiles.Count) 个文件"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 显示前10个变更的文件(用于调试)
|
$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path
|
||||||
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 期望的文件名)
|
|
||||||
$updateZipPath = Join-Path $OutputDir "update.zip"
|
$updateZipPath = Join-Path $OutputDir "update.zip"
|
||||||
Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow
|
New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles
|
||||||
|
|
||||||
if (Test-Path $updateZipPath) {
|
$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion)
|
||||||
Remove-Item -Path $updateZipPath -Force
|
Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force
|
||||||
}
|
|
||||||
|
|
||||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
|
$allEntries = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||||
|
$filesJson = [ordered]@{
|
||||||
# 同时创建带版本号的副本(用于发布到 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 = @{
|
|
||||||
FromVersion = $PreviousVersion
|
FromVersion = $PreviousVersion
|
||||||
ToVersion = $CurrentVersion
|
ToVersion = $CurrentVersion
|
||||||
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
|
GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o")
|
||||||
Files = @($changedFiles + $reusedFiles + $deletedFiles)
|
Files = $allEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$jsonText = $filesJson | ConvertTo-Json -Depth 10
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
|
||||||
$filesJsonPath = Join-Path $OutputDir "files.json"
|
$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)
|
$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length
|
||||||
$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
|
$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2)
|
||||||
Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow
|
|
||||||
Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force
|
|
||||||
|
|
||||||
# 计算增量包大小
|
|
||||||
$updateSize = (Get-Item $updateZipPath).Length
|
|
||||||
$updateSizeMB = [math]::Round($updateSize / 1MB, 2)
|
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
Write-Host "Done."
|
||||||
Write-Host "增量包大小: $updateSizeMB MB"
|
Write-Host "update.zip size: $updateSizeMb MB"
|
||||||
Write-Host "输出文件 (Launcher 使用):"
|
Write-Host "Generated:"
|
||||||
Write-Host " - $updateZipPath"
|
Write-Host " $updateZipPath"
|
||||||
Write-Host " - $filesJsonPath"
|
Write-Host " $filesJsonPath"
|
||||||
Write-Host "输出文件 (GitHub Release 发布):"
|
Write-Host " $deltaZipPath"
|
||||||
Write-Host " - $deltaZipPath"
|
Write-Host " $versionedFilesJsonPath"
|
||||||
Write-Host " - $versionedFilesJsonPath"
|
|
||||||
|
|||||||
@@ -1,65 +1,56 @@
|
|||||||
# Sign-FileMap.ps1
|
|
||||||
# 对 files.json 进行 RSA 签名
|
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$FilesJsonPath,
|
[string]$FilesJsonPath,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$PrivateKeyPath,
|
[string]$PrivateKeyPath,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory = $false)]
|
||||||
[string]$OutputPath
|
[string]$OutputPath
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
Write-Host "=== 签名文件清单 ===" -ForegroundColor Cyan
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
Write-Host "文件清单: $FilesJsonPath"
|
throw "Sign-FileMap.ps1 requires PowerShell 7 or newer."
|
||||||
Write-Host "私钥: $PrivateKeyPath"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 检查文件是否存在
|
|
||||||
if (-not (Test-Path $FilesJsonPath)) {
|
|
||||||
Write-Error "文件清单不存在: $FilesJsonPath"
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-Path $PrivateKeyPath)) {
|
if (-not (Test-Path -LiteralPath $FilesJsonPath)) {
|
||||||
Write-Error "私钥文件不存在: $PrivateKeyPath"
|
throw "Manifest file not found: $FilesJsonPath"
|
||||||
exit 1
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
|
||||||
|
throw "Private key file not found: $PrivateKeyPath"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 确定输出路径
|
|
||||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||||
$OutputPath = "$FilesJsonPath.sig"
|
$OutputPath = "$FilesJsonPath.sig"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 读取文件内容
|
$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path
|
||||||
$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath)
|
$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath)
|
||||||
|
|
||||||
# 读取私钥
|
$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw
|
||||||
$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw
|
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||||
|
throw "Private key PEM is empty: $PrivateKeyPath"
|
||||||
# 使用 .NET 进行 RSA 签名
|
}
|
||||||
Add-Type -AssemblyName System.Security.Cryptography
|
|
||||||
|
|
||||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
$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()
|
||||||
|
}
|
||||||
|
|
||||||
# 生成签名
|
$signatureBase64 = [Convert]::ToBase64String($signatureBytes)
|
||||||
$signature = $rsa.SignData(
|
[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII)
|
||||||
$jsonBytes,
|
|
||||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
|
||||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
|
||||||
)
|
|
||||||
|
|
||||||
# 转换为 Base64
|
Write-Host "Signed manifest file."
|
||||||
$signatureBase64 = [Convert]::ToBase64String($signature)
|
Write-Host "Manifest: $FilesJsonPath"
|
||||||
|
Write-Host "Signature: $OutputPath"
|
||||||
# 写入签名文件
|
|
||||||
Set-Content -Path $OutputPath -Value $signatureBase64 -Encoding ASCII
|
|
||||||
|
|
||||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
|
||||||
Write-Host "签名文件: $OutputPath"
|
|
||||||
Write-Host "签名长度: $($signature.Length) 字节"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user