mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23: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'
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
DOTNET_gcServer: 1
|
||||
ENABLE_LEGACY_DELTA_FALLBACK: 'false'
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
@@ -318,56 +317,20 @@ jobs:
|
||||
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
|
||||
shell: pwsh
|
||||
|
||||
- name: Install vpk
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
- name: Build Signed FileMap Update Package
|
||||
if: matrix.self_contained == true
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
dotnet tool uninstall --global vpk | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "vpk is not preinstalled, proceeding with fresh install."
|
||||
}
|
||||
dotnet tool install --global vpk --allow-roll-forward
|
||||
"$env:USERPROFILE\\.dotnet\\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
$env:PATH = "$env:USERPROFILE\\.dotnet\\tools;$env:PATH"
|
||||
vpk -h
|
||||
shell: pwsh
|
||||
|
||||
- name: Prepare Previous Velopack Full Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
run: |
|
||||
$outputDir = "velopack-output"
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
|
||||
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
|
||||
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
|
||||
if ($previousRelease) {
|
||||
$previousFull = $previousRelease.assets |
|
||||
Where-Object { $_.name -like "*-full.nupkg" } |
|
||||
Select-Object -First 1
|
||||
if ($previousFull) {
|
||||
$dest = Join-Path $outputDir $previousFull.name
|
||||
Invoke-WebRequest -Uri $previousFull.browser_download_url -OutFile $dest -Headers $headers
|
||||
Write-Host "Downloaded previous package for Velopack delta generation."
|
||||
} else {
|
||||
Write-Host "No previous full package found. Velopack will generate full package only."
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Could not fetch previous release package: $_"
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Build Velopack Packages
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$platform = "windows-$arch"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
$outputDir = "velopack-output"
|
||||
$outputDir = Join-Path "delta-output" $platform
|
||||
$generateScript = "scripts/Generate-DeltaPackage.ps1"
|
||||
$signScript = "scripts/Sign-FileMap.ps1"
|
||||
|
||||
if (-not (Test-Path $currentAppPath)) {
|
||||
Write-Error "Expected app directory not found: $currentAppPath"
|
||||
@@ -375,54 +338,65 @@ jobs:
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
vpk pack `
|
||||
--packId LanMountainDesktop `
|
||||
--packVersion $version `
|
||||
--packDir $currentAppPath `
|
||||
--mainExe LanMountainDesktop.exe `
|
||||
--outputDir $outputDir `
|
||||
--channel win `
|
||||
--noPortable `
|
||||
--skipVeloAppCheck
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Velopack packaging failed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Get-ChildItem -Path $outputDir -File | Select-Object Name,Length
|
||||
shell: pwsh
|
||||
|
||||
- name: Legacy Delta Fallback (disabled by default)
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64' && env.ENABLE_LEGACY_DELTA_FALLBACK == 'true'
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
$outputDir = "delta-output"
|
||||
$scriptPath = "scripts/Generate-DeltaPackage.ps1"
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
& $scriptPath `
|
||||
& $generateScript `
|
||||
-PreviousVersion "0.0.0" `
|
||||
-CurrentVersion $version `
|
||||
-PreviousDir $currentAppPath `
|
||||
-CurrentDir $currentAppPath `
|
||||
-OutputDir $outputDir
|
||||
|
||||
$privateKeyPem = @'
|
||||
${{ secrets.PDC_SIGNING_KEY }}
|
||||
'@.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
$privateKeyPem = @'
|
||||
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||
'@.Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
|
||||
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
$privateKeyPath = Join-Path $tempDir "private-key.pem"
|
||||
$publicKeyPath = Join-Path $tempDir "public-key.pem"
|
||||
|
||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
|
||||
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
|
||||
|
||||
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
|
||||
$repoPublicKey = (Get-Content -Path $repoPublicKeyPath -Raw).Trim()
|
||||
if ($repoPublicKey -ne $derivedPublicKey.Trim()) {
|
||||
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
|
||||
exit 1
|
||||
}
|
||||
|
||||
& $signScript `
|
||||
-FilesJsonPath (Join-Path $outputDir "files.json") `
|
||||
-PrivateKeyPath $privateKeyPath `
|
||||
-OutputPath (Join-Path $outputDir "files.json.sig")
|
||||
|
||||
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
|
||||
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
|
||||
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Velopack Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
- name: Upload Signed FileMap Update Package
|
||||
if: matrix.self_contained == true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-velopack-windows-x64
|
||||
name: release-update-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
velopack-output/*.nupkg
|
||||
velopack-output/releases.win.json
|
||||
velopack-output/assets.win.json
|
||||
velopack-output/RELEASES
|
||||
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json
|
||||
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json.sig
|
||||
delta-output/windows-${{ matrix.arch }}/update-windows-${{ matrix.arch }}.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
- name: Upload Installer
|
||||
@@ -630,6 +604,86 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Signed FileMap Update Package
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$platform = "linux-x64"
|
||||
$publishDir = "publish/linux-x64"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
$outputDir = Join-Path "delta-output" $platform
|
||||
$generateScript = "scripts/Generate-DeltaPackage.ps1"
|
||||
$signScript = "scripts/Sign-FileMap.ps1"
|
||||
|
||||
if (-not (Test-Path $currentAppPath)) {
|
||||
Write-Error "Expected app directory not found: $currentAppPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
& $generateScript `
|
||||
-PreviousVersion "0.0.0" `
|
||||
-CurrentVersion $version `
|
||||
-PreviousDir $currentAppPath `
|
||||
-CurrentDir $currentAppPath `
|
||||
-OutputDir $outputDir
|
||||
|
||||
$privateKeyPem = @'
|
||||
${{ secrets.PDC_SIGNING_KEY }}
|
||||
'@.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
$privateKeyPem = @'
|
||||
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||
'@.Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
|
||||
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
$privateKeyPath = Join-Path $tempDir "private-key.pem"
|
||||
$publicKeyPath = Join-Path $tempDir "public-key.pem"
|
||||
|
||||
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
|
||||
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
|
||||
|
||||
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
|
||||
$repoPublicKey = (Get-Content -Path $repoPublicKeyPath -Raw).Trim()
|
||||
if ($repoPublicKey -ne $derivedPublicKey.Trim()) {
|
||||
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
|
||||
exit 1
|
||||
}
|
||||
|
||||
& $signScript `
|
||||
-FilesJsonPath (Join-Path $outputDir "files.json") `
|
||||
-PrivateKeyPath $privateKeyPath `
|
||||
-OutputPath (Join-Path $outputDir "files.json.sig")
|
||||
|
||||
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
|
||||
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
|
||||
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
|
||||
|
||||
- name: Upload Signed FileMap Update Package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-update-linux-x64
|
||||
path: |
|
||||
delta-output/linux-x64/files-linux-x64.json
|
||||
delta-output/linux-x64/files-linux-x64.json.sig
|
||||
delta-output/linux-x64/update-linux-x64.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -832,8 +886,8 @@ jobs:
|
||||
mkdir -p release-files
|
||||
# Copy installers and packages
|
||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
||||
# Copy Velopack release feed and update packages
|
||||
find artifacts -type f \( -name "releases.win.json" -o -name "assets.win.json" -o -name "RELEASES" -o -name "*.nupkg" \) -exec cp -v {} release-files/ \;
|
||||
# Copy signed file-map incremental update assets
|
||||
find artifacts -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
|
||||
echo ""
|
||||
echo "Files ready for release:"
|
||||
ls -lh release-files/ || echo "No files found in release-files"
|
||||
@@ -846,6 +900,38 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Incremental Assets to S3 (optional)
|
||||
if: ${{ vars.S3_ENDPOINT != '' && vars.S3_BUCKET != '' && secrets.S3_ACCESS_KEY != '' && secrets.S3_SECRET_KEY != '' }}
|
||||
env:
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_REGION: ${{ vars.S3_REGION != '' && vars.S3_REGION || 'cn-nb1' }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_OBJECT_PREFIX: lanmountain/distribution-v1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m pip install --upgrade awscli
|
||||
|
||||
mkdir -p release-update-assets
|
||||
find release-files -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-update-assets/ \;
|
||||
|
||||
asset_count=$(find release-update-assets -type f | wc -l)
|
||||
if [ "$asset_count" -eq 0 ]; then
|
||||
echo "Error: no incremental update assets found for S3 upload."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY"
|
||||
export AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY"
|
||||
export AWS_DEFAULT_REGION="$S3_REGION"
|
||||
|
||||
version_prefix="${S3_OBJECT_PREFIX}/${{ needs.prepare.outputs.version }}/"
|
||||
latest_prefix="${S3_OBJECT_PREFIX}/latest/"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${version_prefix}" --only-show-errors
|
||||
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${latest_prefix}" --delete --only-show-errors
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
@@ -867,12 +953,12 @@ jobs:
|
||||
|
||||
Installation: Double-click the .exe file and follow the wizard.
|
||||
|
||||
### Incremental Update (Windows x64)
|
||||
- **releases.win.json** - Velopack release feed consumed by the launcher update flow
|
||||
- **LanMountainDesktop-<version>-full.nupkg** - full package
|
||||
- **LanMountainDesktop-<version>-delta.nupkg** - delta package (when available)
|
||||
### Incremental Update Assets
|
||||
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
|
||||
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
|
||||
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
|
||||
|
||||
Existing users: The app will automatically detect and apply the incremental update on next launch.
|
||||
Existing users: Launcher will detect platform-matching signed assets and apply update on next startup.
|
||||
|
||||
### Linux
|
||||
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)
|
||||
|
||||
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] Launcher pending update check supports VeloPack payload.
|
||||
- [x] Launcher apply uses deployment markers (`.current/.partial/.destroy`) unchanged.
|
||||
- [x] Legacy script path retained as emergency fallback.
|
||||
- [ ] Staging verification report attached.
|
||||
- [x] Spec marked as deprecated.
|
||||
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
|
||||
- [x] No release workflow dependency remains on VeloPack.
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
# VeloPack Update Integration
|
||||
# VeloPack Update Integration (Deprecated)
|
||||
|
||||
## Goal
|
||||
Switch incremental package generation and release assets to VeloPack native outputs while keeping Launcher as the update installer and rollback authority.
|
||||
## Status
|
||||
|
||||
## Requirements
|
||||
- CI/release pipeline produces `releases.win.json` and `*.nupkg` assets for Windows x64.
|
||||
- Launcher can detect pending VeloPack payload in `.launcher/update/incoming`.
|
||||
- Launcher applies update into new `app-*` deployment and preserves rollback snapshot behavior.
|
||||
- Existing launcher responsibilities (OOBE/startup/plugin upgrade) remain unchanged.
|
||||
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
|
||||
|
||||
## Acceptance
|
||||
- Build and quality workflows pass after migration changes.
|
||||
- Release workflow publishes VeloPack assets.
|
||||
- Launcher `update apply` succeeds with VeloPack full package payload.
|
||||
- Manual rollback still works after a VeloPack-based update.
|
||||
## Deprecation Reason
|
||||
|
||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||
|
||||
## Migration Note
|
||||
|
||||
Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# Tasks
|
||||
# Tasks (Deprecated)
|
||||
|
||||
- [x] Fix Launcher `LoadingDetailsWindow.axaml` compile regression.
|
||||
- [x] Add VeloPack feed/package model support in Launcher update engine.
|
||||
- [x] Keep legacy delta flow behind disabled fallback switch.
|
||||
- [x] Migrate release workflow packaging assets to VeloPack outputs.
|
||||
- [x] Update host-side update workflow to download VeloPack payload files.
|
||||
- [ ] Run full release workflow dry-run on GitHub and validate artifacts.
|
||||
- [ ] Validate end-to-end update + rollback on a staging machine.
|
||||
- [x] Mark VeloPack integration spec as deprecated.
|
||||
- [x] Remove VeloPack runtime branches from launcher/host update path.
|
||||
- [x] Remove VeloPack release workflow packaging steps.
|
||||
- [ ] Keep archive for historical context only (no new implementation tasks here).
|
||||
|
||||
@@ -20,7 +20,4 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSerializable(typeof(GitHubRelease))]
|
||||
[JsonSerializable(typeof(GitHubAsset))]
|
||||
[JsonSerializable(typeof(List<GitHubRelease>))]
|
||||
[JsonSerializable(typeof(VelopackReleaseFeed))]
|
||||
[JsonSerializable(typeof(VelopackReleaseAsset))]
|
||||
[JsonSerializable(typeof(List<VelopackReleaseAsset>))]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -11,8 +11,6 @@ public sealed class ReleaseInfo
|
||||
public required DateTime PublishedAt { get; init; }
|
||||
public required List<ReleaseAsset> Assets { get; init; }
|
||||
public string? Body { get; init; }
|
||||
public string? VelopackFeedUrl { get; init; }
|
||||
public string? VelopackLegacyReleasesUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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(
|
||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||
|
||||
@@ -104,11 +104,7 @@ internal sealed class UpdateCheckService
|
||||
Name = a.Name ?? "",
|
||||
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
||||
Size = a.Size
|
||||
}).ToList() ?? [],
|
||||
VelopackFeedUrl = r.Assets?.FirstOrDefault(a =>
|
||||
string.Equals(a.Name, "releases.win.json", StringComparison.OrdinalIgnoreCase))?.BrowserDownloadUrl,
|
||||
VelopackLegacyReleasesUrl = r.Assets?.FirstOrDefault(a =>
|
||||
string.Equals(a.Name, "RELEASES", StringComparison.OrdinalIgnoreCase))?.BrowserDownloadUrl
|
||||
}).ToList() ?? []
|
||||
}).ToList() ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ internal sealed class UpdateEngineService
|
||||
private const string SignedFileMapName = "files.json";
|
||||
private const string SignatureFileName = "files.json.sig";
|
||||
private const string ArchiveFileName = "update.zip";
|
||||
private const string VelopackReleasesFileName = "releases.win.json";
|
||||
private const string PublicKeyFileName = "public-key.pem";
|
||||
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
@@ -34,16 +33,6 @@ internal sealed class UpdateEngineService
|
||||
|
||||
public LauncherResult CheckPendingUpdate()
|
||||
{
|
||||
var velopackFeedPath = Path.Combine(_incomingRoot, VelopackReleasesFileName);
|
||||
if (File.Exists(velopackFeedPath))
|
||||
{
|
||||
var velopackResult = CheckVelopackPendingUpdate(velopackFeedPath);
|
||||
if (velopackResult is not null)
|
||||
{
|
||||
return velopackResult;
|
||||
}
|
||||
}
|
||||
|
||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
@@ -82,47 +71,6 @@ internal sealed class UpdateEngineService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<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)
|
||||
{
|
||||
Directory.CreateDirectory(_incomingRoot);
|
||||
@@ -167,12 +115,6 @@ internal sealed class UpdateEngineService
|
||||
Directory.CreateDirectory(_incomingRoot);
|
||||
Directory.CreateDirectory(_snapshotsRoot);
|
||||
|
||||
var velopackFeedPath = Path.Combine(_incomingRoot, VelopackReleasesFileName);
|
||||
if (File.Exists(velopackFeedPath))
|
||||
{
|
||||
return await ApplyVelopackPendingUpdateAsync(velopackFeedPath).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
@@ -631,8 +573,7 @@ internal sealed class UpdateEngineService
|
||||
{
|
||||
Path.Combine(_incomingRoot, SignedFileMapName),
|
||||
Path.Combine(_incomingRoot, SignatureFileName),
|
||||
Path.Combine(_incomingRoot, ArchiveFileName),
|
||||
Path.Combine(_incomingRoot, VelopackReleasesFileName)
|
||||
Path.Combine(_incomingRoot, ArchiveFileName)
|
||||
})
|
||||
{
|
||||
try
|
||||
@@ -646,17 +587,6 @@ internal sealed class UpdateEngineService
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var nupkgPath in Directory.EnumerateFiles(_incomingRoot, "*.nupkg", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
File.Delete(nupkgPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath)
|
||||
@@ -724,307 +654,6 @@ internal sealed class UpdateEngineService
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private LauncherResult? CheckVelopackPendingUpdate(string feedPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var feed = JsonSerializer.Deserialize(File.ReadAllText(feedPath), AppJsonContext.Default.VelopackReleaseFeed);
|
||||
if (feed?.Assets is null || feed.Assets.Count == 0)
|
||||
{
|
||||
return Failed("update.check", "invalid_manifest", "releases.win.json is invalid.");
|
||||
}
|
||||
|
||||
var currentVersion = ParseVersionSafe(_deploymentLocator.GetCurrentVersion());
|
||||
var latest = feed.Assets
|
||||
.Where(a => string.Equals(a.Type, "Full", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(a => new { Asset = a, Version = ParseVersionSafe(a.Version) })
|
||||
.Where(x => x.Version > currentVersion)
|
||||
.OrderByDescending(x => x.Version)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (latest is null)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.check",
|
||||
Code = "noop",
|
||||
Message = "No pending update for current version."
|
||||
};
|
||||
}
|
||||
|
||||
var packagePath = Path.Combine(_incomingRoot, latest.Asset.FileName);
|
||||
if (!File.Exists(packagePath))
|
||||
{
|
||||
return Failed("update.check", "missing_payload", $"Missing Velopack package '{latest.Asset.FileName}'.");
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.check",
|
||||
Code = "available",
|
||||
Message = "Pending Velopack update is available.",
|
||||
CurrentVersion = _deploymentLocator.GetCurrentVersion(),
|
||||
TargetVersion = latest.Asset.Version
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failed("update.check", "invalid_manifest", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<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)
|
||||
{
|
||||
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 UpdateDownloadSource { get; set; } = "github";
|
||||
public string UpdateDownloadSource { get; set; } = "pdc";
|
||||
|
||||
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
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
|
||||
private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new();
|
||||
|
||||
public UpdateSettingsService(ISettingsService settingsService)
|
||||
{
|
||||
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||
@@ -838,7 +839,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
bool includePrerelease,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
@@ -849,7 +850,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.DownloadAssetAsync(
|
||||
return _githubReleaseUpdateService.DownloadAssetAsync(
|
||||
asset,
|
||||
destinationFilePath,
|
||||
downloadSource,
|
||||
@@ -866,7 +867,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _releaseUpdateService.RedownloadAssetAsync(
|
||||
return _githubReleaseUpdateService.RedownloadAssetAsync(
|
||||
asset,
|
||||
destinationFilePath,
|
||||
downloadSource,
|
||||
@@ -877,7 +878,36 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_releaseUpdateService.Dispose();
|
||||
_githubReleaseUpdateService.Dispose();
|
||||
_pdcReleaseUpdateService.Dispose();
|
||||
}
|
||||
|
||||
private async Task<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 ModeSilentOnExit = "silent_on_exit";
|
||||
|
||||
public const string DownloadSourcePdc = "pdc";
|
||||
public const string DownloadSourceGitHub = "github";
|
||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||
|
||||
@@ -51,9 +52,23 @@ public static class UpdateSettingsValues
|
||||
|
||||
public static string NormalizeDownloadSource(string? value)
|
||||
{
|
||||
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
|
||||
? DownloadSourceGhProxy
|
||||
: DownloadSourceGitHub;
|
||||
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourcePdc;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceGhProxy;
|
||||
}
|
||||
|
||||
if (string.Equals(value, DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceGitHub;
|
||||
}
|
||||
|
||||
// Default to PDC. Runtime will fallback to GitHub if PDC is unavailable.
|
||||
return DownloadSourcePdc;
|
||||
}
|
||||
|
||||
public static int NormalizeDownloadThreads(int value)
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
@@ -52,7 +53,9 @@ public sealed class UpdateWorkflowService
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string VelopackReleasesFileName = "releases.win.json";
|
||||
private const string SignedFileMapName = "files.json";
|
||||
private const string SignedFileMapSignatureName = "files.json.sig";
|
||||
private const string UpdateArchiveName = "update.zip";
|
||||
|
||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
@@ -79,7 +82,7 @@ public sealed class UpdateWorkflowService
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
||||
{
|
||||
@@ -88,13 +91,11 @@ public sealed class UpdateWorkflowService
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasFeed = release.Assets.Any(a => string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase));
|
||||
var hasFull = release.Assets.Any(a => a.Name.EndsWith("-full.nupkg", StringComparison.OrdinalIgnoreCase));
|
||||
return hasFeed && hasFull;
|
||||
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
@@ -108,11 +109,9 @@ public sealed class UpdateWorkflowService
|
||||
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
||||
}
|
||||
|
||||
var releasesFeedAsset = checkResult.Release.Assets.FirstOrDefault(a =>
|
||||
string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase));
|
||||
if (releasesFeedAsset is null)
|
||||
if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Release does not contain releases.win.json.");
|
||||
return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
|
||||
}
|
||||
|
||||
var incomingDir = GetLauncherIncomingDirectory();
|
||||
@@ -130,29 +129,19 @@ public sealed class UpdateWorkflowService
|
||||
var downloadSource = state.UpdateDownloadSource;
|
||||
var downloadThreads = state.UpdateDownloadThreads;
|
||||
|
||||
var latestVersionText = checkResult.LatestVersionText.Trim();
|
||||
var targetPackages = checkResult.Release.Assets
|
||||
.Where(a => a.Name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a => a.Name.Contains(latestVersionText, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a =>
|
||||
a.Name.EndsWith("-full.nupkg", StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Name.EndsWith("-delta.nupkg", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (targetPackages.Count == 0)
|
||||
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No Velopack nupkg asset found for the target version.");
|
||||
}
|
||||
|
||||
var requiredAssets = new List<GitHubReleaseAsset> { releasesFeedAsset };
|
||||
requiredAssets.AddRange(targetPackages);
|
||||
(manifestAsset, SignedFileMapName),
|
||||
(signatureAsset, SignedFileMapSignatureName),
|
||||
(archiveAsset, UpdateArchiveName)
|
||||
};
|
||||
|
||||
var totalAssets = requiredAssets.Count;
|
||||
var completedAssets = 0;
|
||||
|
||||
foreach (var asset in requiredAssets)
|
||||
foreach (var (asset, destinationFileName) in requiredAssets)
|
||||
{
|
||||
var destinationPath = Path.Combine(incomingDir, asset.Name);
|
||||
var destinationPath = Path.Combine(incomingDir, destinationFileName);
|
||||
|
||||
// Skip if already downloaded and file exists
|
||||
if (File.Exists(destinationPath))
|
||||
@@ -160,7 +149,7 @@ public sealed class UpdateWorkflowService
|
||||
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
||||
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", $"Velopack asset {asset.Name} already downloaded with matching hash, skipping.");
|
||||
AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping.");
|
||||
completedAssets++;
|
||||
progress?.Report((double)completedAssets / totalAssets);
|
||||
continue;
|
||||
@@ -184,21 +173,21 @@ public sealed class UpdateWorkflowService
|
||||
if (!result.Success)
|
||||
{
|
||||
// Clean up partially downloaded files
|
||||
foreach (var file in requiredAssets.Select(a => a.Name))
|
||||
foreach (var file in requiredAssets.Select(a => a.DestinationFileName))
|
||||
{
|
||||
try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
|
||||
}
|
||||
return new UpdateDownloadResult(false, null, $"Failed to download Velopack asset {asset.Name}: {result.ErrorMessage}");
|
||||
return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
completedAssets++;
|
||||
progress?.Report((double)completedAssets / totalAssets);
|
||||
}
|
||||
|
||||
// Save state indicating a Velopack update is pending.
|
||||
// Save state indicating a signed file-map update is pending.
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, VelopackReleasesFileName),
|
||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
@@ -207,9 +196,9 @@ public sealed class UpdateWorkflowService
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
|
||||
AppLogger.Info("UpdateWorkflow", $"Velopack update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
||||
AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
||||
|
||||
return new UpdateDownloadResult(true, Path.Combine(incomingDir, VelopackReleasesFileName), null);
|
||||
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -224,11 +213,71 @@ public sealed class UpdateWorkflowService
|
||||
return false;
|
||||
}
|
||||
|
||||
// Velopack updates are identified by the releases feed path.
|
||||
return pendingPath.EndsWith(VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase)
|
||||
// Incoming payload updates are identified by files.json or incoming directory path.
|
||||
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryResolveDeltaAssets(
|
||||
IReadOnlyList<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()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
|
||||
@@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
|
||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||
@@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _previewChannelText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _pdcSourceText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _gitHubSourceText = string.Empty;
|
||||
|
||||
@@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
public bool IsPreviewChannelSelected =>
|
||||
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsPdcSourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsGitHubSourceSelected =>
|
||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectPdcSource()
|
||||
{
|
||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectGitHubSource()
|
||||
{
|
||||
@@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
DownloadProgressValue = 0;
|
||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||
UpdateStatus = isForce
|
||||
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
|
||||
: L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
? L("settings.update.status_force_checking", "Force checking update source...")
|
||||
: L("settings.update.status_checking", "Checking update source...");
|
||||
|
||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
||||
_lastCheckResult = result.Success ? result : null;
|
||||
@@ -2100,7 +2112,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
||||
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
|
||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
|
||||
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
||||
@@ -2112,6 +2124,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
|
||||
StableChannelText = L("settings.update.channel_stable", "Stable");
|
||||
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
||||
PdcSourceText = L("settings.update.source_pdc", "PDC");
|
||||
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
||||
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
||||
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
||||
@@ -2309,6 +2322,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
|
||||
{
|
||||
UpdateSettingsValues.DownloadSourcePdc => L(
|
||||
"settings.update.source_pdc_desc",
|
||||
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
|
||||
UpdateSettingsValues.DownloadSourceGhProxy => L(
|
||||
"settings.update.source_ghproxy_desc",
|
||||
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
||||
@@ -2360,6 +2376,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
||||
];
|
||||
|
||||
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(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PreviousVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PreviousDir,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentDir,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
|
||||
Write-Host "从版本: $PreviousVersion"
|
||||
Write-Host "到版本: $CurrentVersion"
|
||||
Write-Host "上一版本目录: $PreviousDir"
|
||||
Write-Host "当前版本目录: $CurrentDir"
|
||||
Write-Host "输出目录: $OutputDir"
|
||||
Write-Host ""
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
# 确保输出目录存在
|
||||
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||
function Get-NormalizedRelativePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootDir,
|
||||
|
||||
# 计算文件 SHA256
|
||||
function Get-FileSha256 {
|
||||
param([string]$Path)
|
||||
$hash = Get-FileHash -Path $Path -Algorithm SHA256
|
||||
return $hash.Hash.ToLower()
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FullPath
|
||||
)
|
||||
|
||||
$root = [System.IO.Path]::GetFullPath($RootDir)
|
||||
$path = [System.IO.Path]::GetFullPath($FullPath)
|
||||
|
||||
if (-not $root.EndsWith([System.IO.Path]::DirectorySeparatorChar.ToString()) -and
|
||||
-not $root.EndsWith([System.IO.Path]::AltDirectorySeparatorChar.ToString())) {
|
||||
$root += [System.IO.Path]::DirectorySeparatorChar
|
||||
}
|
||||
|
||||
$rootUri = [System.Uri]$root
|
||||
$pathUri = [System.Uri]$path
|
||||
$relative = [System.Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString())
|
||||
|
||||
return $relative.Replace('\', '/')
|
||||
}
|
||||
|
||||
function Get-FileSha256Hex {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
# 获取目录中所有文件的相对路径和哈希
|
||||
function Get-FileManifest {
|
||||
param([string]$RootDir)
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RootDir
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RootDir)) {
|
||||
throw "Directory does not exist: $RootDir"
|
||||
}
|
||||
|
||||
$resolvedRoot = (Resolve-Path -LiteralPath $RootDir).Path
|
||||
$manifest = @{}
|
||||
$files = Get-ChildItem -Path $RootDir -Recurse -File
|
||||
|
||||
$files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File
|
||||
|
||||
foreach ($file in $files) {
|
||||
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
|
||||
$relativePath = $relativePath.Replace('\', '/')
|
||||
|
||||
$manifest[$relativePath] = @{
|
||||
$relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName
|
||||
$manifest[$relativePath] = [ordered]@{
|
||||
Path = $relativePath
|
||||
Sha256 = Get-FileSha256 -Path $file.FullName
|
||||
Size = $file.Length
|
||||
Sha256 = Get-FileSha256Hex -Path $file.FullName
|
||||
Size = [long]$file.Length
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $manifest
|
||||
}
|
||||
|
||||
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
|
||||
Write-Host " 目录: $PreviousDir" -ForegroundColor Gray
|
||||
if (-not (Test-Path $PreviousDir)) {
|
||||
throw "Previous directory does not exist: $PreviousDir"
|
||||
function New-DeltaArchive {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ZipPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CurrentRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object[]]$ChangedFiles
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $ZipPath) {
|
||||
Remove-Item -LiteralPath $ZipPath -Force
|
||||
}
|
||||
|
||||
$zip = [System.IO.Compression.ZipFile]::Open($ZipPath, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($file in $ChangedFiles) {
|
||||
$sourcePath = Join-Path $CurrentRoot $file.Path
|
||||
if (-not (Test-Path -LiteralPath $sourcePath)) {
|
||||
throw "Changed file was not found while building archive: $sourcePath"
|
||||
}
|
||||
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
|
||||
$zip,
|
||||
$sourcePath,
|
||||
$file.Path,
|
||||
[System.IO.Compression.CompressionLevel]::Optimal
|
||||
) | Out-Null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$zip.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Generating incremental package..."
|
||||
Write-Host "From: $PreviousVersion"
|
||||
Write-Host "To: $CurrentVersion"
|
||||
Write-Host "Prev: $PreviousDir"
|
||||
Write-Host "Curr: $CurrentDir"
|
||||
Write-Host "Out: $OutputDir"
|
||||
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
$previousManifest = Get-FileManifest -RootDir $PreviousDir
|
||||
Write-Host " 找到 $($previousManifest.Count) 个文件" -ForegroundColor Gray
|
||||
|
||||
Write-Host "扫描当前版本文件..." -ForegroundColor Yellow
|
||||
Write-Host " 目录: $CurrentDir" -ForegroundColor Gray
|
||||
if (-not (Test-Path $CurrentDir)) {
|
||||
throw "Current directory does not exist: $CurrentDir"
|
||||
}
|
||||
$currentManifest = Get-FileManifest -RootDir $CurrentDir
|
||||
Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray
|
||||
|
||||
# 分析文件变更
|
||||
$changedFiles = @()
|
||||
$reusedFiles = @()
|
||||
$deletedFiles = @()
|
||||
|
||||
Write-Host "分析文件变更..." -ForegroundColor Yellow
|
||||
|
||||
# 检查新增和修改的文件
|
||||
foreach ($path in $currentManifest.Keys) {
|
||||
foreach ($path in ($currentManifest.Keys | Sort-Object)) {
|
||||
$currentFile = $currentManifest[$path]
|
||||
|
||||
|
||||
if ($previousManifest.ContainsKey($path)) {
|
||||
$previousFile = $previousManifest[$path]
|
||||
|
||||
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
|
||||
# 文件未变更,可以复用
|
||||
$reusedFiles += @{
|
||||
$reusedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "reuse"
|
||||
Sha256 = $currentFile.Sha256
|
||||
Size = $currentFile.Size
|
||||
}
|
||||
} else {
|
||||
# 文件已修改
|
||||
$changedFiles += @{
|
||||
}
|
||||
else {
|
||||
$changedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "replace"
|
||||
Sha256 = $currentFile.Sha256
|
||||
@@ -107,9 +154,9 @@ foreach ($path in $currentManifest.Keys) {
|
||||
ArchivePath = $path
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# 新增文件
|
||||
$changedFiles += @{
|
||||
}
|
||||
else {
|
||||
$changedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "add"
|
||||
Sha256 = $currentFile.Sha256
|
||||
@@ -119,104 +166,51 @@ foreach ($path in $currentManifest.Keys) {
|
||||
}
|
||||
}
|
||||
|
||||
# 检查删除的文件
|
||||
foreach ($path in $previousManifest.Keys) {
|
||||
foreach ($path in ($previousManifest.Keys | Sort-Object)) {
|
||||
if (-not $currentManifest.ContainsKey($path)) {
|
||||
$deletedFiles += @{
|
||||
$deletedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "变更统计:" -ForegroundColor Green
|
||||
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
|
||||
Write-Host " 复用: $($reusedFiles.Count) 个文件"
|
||||
Write-Host " 删除: $($deletedFiles.Count) 个文件"
|
||||
Write-Host ""
|
||||
Write-Host "Changed: $($changedFiles.Count)"
|
||||
Write-Host "Reused: $($reusedFiles.Count)"
|
||||
Write-Host "Deleted: $($deletedFiles.Count)"
|
||||
|
||||
# 显示前10个变更的文件(用于调试)
|
||||
if ($changedFiles.Count -gt 0) {
|
||||
Write-Host "变更的文件示例:" -ForegroundColor Cyan
|
||||
$changedFiles | Select-Object -First 10 | ForEach-Object {
|
||||
Write-Host " [$($_.Action)] $($_.Path)" -ForegroundColor Gray
|
||||
}
|
||||
if ($changedFiles.Count -gt 10) {
|
||||
Write-Host " ... 还有 $($changedFiles.Count - 10) 个文件" -ForegroundColor Gray
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# 创建临时目录用于打包
|
||||
$tempDir = Join-Path $OutputDir "temp_delta"
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
|
||||
|
||||
# 复制变更的文件到临时目录
|
||||
Write-Host "复制变更文件..." -ForegroundColor Yellow
|
||||
foreach ($file in $changedFiles) {
|
||||
$sourcePath = Join-Path $CurrentDir $file.Path
|
||||
$destPath = Join-Path $tempDir $file.Path
|
||||
$destDir = Split-Path -Parent $destPath
|
||||
|
||||
if (-not (Test-Path $destDir)) {
|
||||
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $sourcePath -Destination $destPath -Force
|
||||
}
|
||||
|
||||
# 创建 update.zip (Launcher 期望的文件名)
|
||||
$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path
|
||||
$updateZipPath = Join-Path $OutputDir "update.zip"
|
||||
Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow
|
||||
New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles
|
||||
|
||||
if (Test-Path $updateZipPath) {
|
||||
Remove-Item -Path $updateZipPath -Force
|
||||
}
|
||||
$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion)
|
||||
Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force
|
||||
|
||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
|
||||
|
||||
# 同时创建带版本号的副本(用于发布到 GitHub Release)
|
||||
$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip"
|
||||
Write-Host "创建带版本号的副本: $deltaZipPath" -ForegroundColor Yellow
|
||||
if (Test-Path $deltaZipPath) {
|
||||
Remove-Item -Path $deltaZipPath -Force
|
||||
}
|
||||
Copy-Item -Path $updateZipPath -Destination $deltaZipPath -Force
|
||||
|
||||
# 清理临时目录
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
|
||||
# 生成 files.json (Launcher 期望的文件名)
|
||||
$filesJson = @{
|
||||
$allEntries = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||
$filesJson = [ordered]@{
|
||||
FromVersion = $PreviousVersion
|
||||
ToVersion = $CurrentVersion
|
||||
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Files = @($changedFiles + $reusedFiles + $deletedFiles)
|
||||
GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o")
|
||||
Files = $allEntries
|
||||
}
|
||||
|
||||
$jsonText = $filesJson | ConvertTo-Json -Depth 10
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
|
||||
$filesJsonPath = Join-Path $OutputDir "files.json"
|
||||
Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow
|
||||
[System.IO.File]::WriteAllText($filesJsonPath, $jsonText, $utf8NoBom)
|
||||
|
||||
$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8
|
||||
$versionedFilesJsonPath = Join-Path $OutputDir ("files-{0}.json" -f $CurrentVersion)
|
||||
Copy-Item -LiteralPath $filesJsonPath -Destination $versionedFilesJsonPath -Force
|
||||
|
||||
# 同时创建带版本号的副本(用于发布到 GitHub Release)
|
||||
$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
|
||||
Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow
|
||||
Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force
|
||||
|
||||
# 计算增量包大小
|
||||
$updateSize = (Get-Item $updateZipPath).Length
|
||||
$updateSizeMB = [math]::Round($updateSize / 1MB, 2)
|
||||
$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length
|
||||
$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
||||
Write-Host "增量包大小: $updateSizeMB MB"
|
||||
Write-Host "输出文件 (Launcher 使用):"
|
||||
Write-Host " - $updateZipPath"
|
||||
Write-Host " - $filesJsonPath"
|
||||
Write-Host "输出文件 (GitHub Release 发布):"
|
||||
Write-Host " - $deltaZipPath"
|
||||
Write-Host " - $versionedFilesJsonPath"
|
||||
Write-Host "Done."
|
||||
Write-Host "update.zip size: $updateSizeMb MB"
|
||||
Write-Host "Generated:"
|
||||
Write-Host " $updateZipPath"
|
||||
Write-Host " $filesJsonPath"
|
||||
Write-Host " $deltaZipPath"
|
||||
Write-Host " $versionedFilesJsonPath"
|
||||
|
||||
@@ -1,65 +1,56 @@
|
||||
# Sign-FileMap.ps1
|
||||
# 对 files.json 进行 RSA 签名
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FilesJsonPath,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PrivateKeyPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== 签名文件清单 ===" -ForegroundColor Cyan
|
||||
Write-Host "文件清单: $FilesJsonPath"
|
||||
Write-Host "私钥: $PrivateKeyPath"
|
||||
Write-Host ""
|
||||
|
||||
# 检查文件是否存在
|
||||
if (-not (Test-Path $FilesJsonPath)) {
|
||||
Write-Error "文件清单不存在: $FilesJsonPath"
|
||||
exit 1
|
||||
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||
throw "Sign-FileMap.ps1 requires PowerShell 7 or newer."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $PrivateKeyPath)) {
|
||||
Write-Error "私钥文件不存在: $PrivateKeyPath"
|
||||
exit 1
|
||||
if (-not (Test-Path -LiteralPath $FilesJsonPath)) {
|
||||
throw "Manifest file not found: $FilesJsonPath"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
|
||||
throw "Private key file not found: $PrivateKeyPath"
|
||||
}
|
||||
|
||||
# 确定输出路径
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = "$FilesJsonPath.sig"
|
||||
}
|
||||
|
||||
# 读取文件内容
|
||||
$jsonBytes = [System.IO.File]::ReadAllBytes($FilesJsonPath)
|
||||
$resolvedManifestPath = (Resolve-Path -LiteralPath $FilesJsonPath).Path
|
||||
$manifestBytes = [System.IO.File]::ReadAllBytes($resolvedManifestPath)
|
||||
|
||||
# 读取私钥
|
||||
$privateKeyPem = Get-Content -Path $PrivateKeyPath -Raw
|
||||
|
||||
# 使用 .NET 进行 RSA 签名
|
||||
Add-Type -AssemblyName System.Security.Cryptography
|
||||
$privateKeyPem = Get-Content -LiteralPath $PrivateKeyPath -Raw
|
||||
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
|
||||
throw "Private key PEM is empty: $PrivateKeyPath"
|
||||
}
|
||||
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
try {
|
||||
$rsa.ImportFromPem($privateKeyPem)
|
||||
$signatureBytes = $rsa.SignData(
|
||||
$manifestBytes,
|
||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||||
)
|
||||
}
|
||||
finally {
|
||||
$rsa.Dispose()
|
||||
}
|
||||
|
||||
# 生成签名
|
||||
$signature = $rsa.SignData(
|
||||
$jsonBytes,
|
||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||||
)
|
||||
$signatureBase64 = [Convert]::ToBase64String($signatureBytes)
|
||||
[System.IO.File]::WriteAllText($OutputPath, $signatureBase64, [System.Text.Encoding]::ASCII)
|
||||
|
||||
# 转换为 Base64
|
||||
$signatureBase64 = [Convert]::ToBase64String($signature)
|
||||
|
||||
# 写入签名文件
|
||||
Set-Content -Path $OutputPath -Value $signatureBase64 -Encoding ASCII
|
||||
|
||||
Write-Host "=== 完成 ===" -ForegroundColor Green
|
||||
Write-Host "签名文件: $OutputPath"
|
||||
Write-Host "签名长度: $($signature.Length) 字节"
|
||||
Write-Host "Signed manifest file."
|
||||
Write-Host "Manifest: $FilesJsonPath"
|
||||
Write-Host "Signature: $OutputPath"
|
||||
|
||||
Reference in New Issue
Block a user