mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
13 Commits
02547eeea6
...
e82c5d41fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e82c5d41fd | ||
|
|
8447910fee | ||
|
|
81e0081721 | ||
|
|
fb21bcd8ec | ||
|
|
62e7d96fe7 | ||
|
|
c5ef418bd9 | ||
|
|
1e6b61db85 | ||
|
|
48ce93b68e | ||
|
|
cddebbcf5a | ||
|
|
24b361b5b9 | ||
|
|
833c69305b | ||
|
|
858612fa8e | ||
|
|
f6a6f97e0b |
316
.github/workflows/release.yml
vendored
316
.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,117 +317,19 @@ 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'
|
||||
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 }}"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$appDir = "app-$version"
|
||||
$currentAppPath = Join-Path $publishDir $appDir
|
||||
$outputDir = "velopack-output"
|
||||
|
||||
if (-not (Test-Path $currentAppPath)) {
|
||||
Write-Error "Expected app directory not found: $currentAppPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
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 `
|
||||
-PreviousVersion "0.0.0" `
|
||||
-CurrentVersion $version `
|
||||
-PreviousDir $currentAppPath `
|
||||
-CurrentDir $currentAppPath `
|
||||
-OutputDir $outputDir
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Velopack Package
|
||||
if: matrix.self_contained == true && matrix.arch == 'x64'
|
||||
- name: Upload App Payload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-velopack-windows-x64
|
||||
name: app-payload-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
velopack-output/*.nupkg
|
||||
velopack-output/releases.win.json
|
||||
velopack-output/assets.win.json
|
||||
velopack-output/RELEASES
|
||||
publish/windows-${{ matrix.arch }}/**
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
|
||||
name: installer-windows-${{ matrix.arch }}
|
||||
path: build-installer/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -630,10 +531,19 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload
|
||||
- name: Upload App Payload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux
|
||||
name: app-payload-linux-x64
|
||||
path: |
|
||||
publish/linux-x64/**
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-linux-x64
|
||||
path: "*.deb"
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -797,23 +707,189 @@ jobs:
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos-${{ matrix.arch }}
|
||||
name: installer-macos-${{ matrix.arch }}
|
||||
path: "*.dmg"
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
github-release:
|
||||
publish-pdc:
|
||||
needs: [ prepare, build-windows, build-linux, build-macos ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
PRIMARY_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
PDCC_primaryVersion: ${{ needs.prepare.outputs.version }}
|
||||
PDCC_VERSION: ${{ vars.PDC_CLIENT_VERSION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
PDC_ENDPOINT: ${{ vars.PDC_ENDPOINT }}
|
||||
PDC_TOKEN: ${{ secrets.PDC_TOKEN }}
|
||||
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
|
||||
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
ref: ${{ needs.prepare.outputs.checkout_ref }}
|
||||
|
||||
- name: Download payload artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: payload-artifacts
|
||||
pattern: app-payload-*
|
||||
|
||||
- name: Download installer artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: installer-artifacts
|
||||
pattern: installer-*
|
||||
|
||||
- name: Prepare PDC environment
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:S3_ENDPOINT) -or
|
||||
[string]::IsNullOrWhiteSpace($env:S3_BUCKET)) {
|
||||
throw "Missing required S3 variables."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:PDC_SIGNING_KEY)) {
|
||||
if ([string]::IsNullOrWhiteSpace($env:UPDATE_PRIVATE_KEY_PEM)) {
|
||||
throw "Missing UPDATE_PRIVATE_KEY_PEM or PDC_SIGNING_KEY."
|
||||
}
|
||||
|
||||
$env:PDC_SIGNING_KEY = $env:UPDATE_PRIVATE_KEY_PEM
|
||||
}
|
||||
|
||||
$workRoot = Join-Path $PWD "pdc-work"
|
||||
if (Test-Path $workRoot) {
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
|
||||
|
||||
$template = Get-Content -Path "phainon.yml" -Raw
|
||||
$resolved = $template `
|
||||
-replace '__FILE_REPO_ROOT__', "$($env:S3_ENDPOINT.TrimEnd('/'))/$($env:S3_BUCKET)/lanmountain/update/repo/" `
|
||||
-replace '__ARCHIVE_ROOT__', "$($env:S3_ENDPOINT.TrimEnd('/'))/$($env:S3_BUCKET)/lanmountain/update/installers/"
|
||||
|
||||
Set-Content -Path (Join-Path $workRoot "phainon.resolved.yml") -Value $resolved -NoNewline
|
||||
|
||||
python3 -m pip install --user --upgrade awscli
|
||||
Add-Content -Path $env:GITHUB_PATH -Value "$HOME/.local/bin"
|
||||
|
||||
- name: Install PDCC
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
./scripts/Install-Pdcc.ps1 -Repository "ClassIsland/PhainonDistributionCenter" -OutputDir "./pdcc"
|
||||
|
||||
- name: Publish with PDCC
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# Map CI vars to the naming convention expected by PDCC tooling.
|
||||
$env:S3_Endpoint = $env:S3_ENDPOINT
|
||||
$env:S3_Bucket = $env:S3_BUCKET
|
||||
$env:S3_Region = $env:S3_REGION
|
||||
$env:PDC_Endpoint = $env:PDC_ENDPOINT
|
||||
$env:PDC_Token = $env:PDC_TOKEN
|
||||
$env:S3_AccessKey = $env:S3_ACCESS_KEY
|
||||
$env:S3_SecretKey = $env:S3_SECRET_KEY
|
||||
if ([string]::IsNullOrWhiteSpace($env:PDC_SigningKey)) {
|
||||
$env:PDC_SigningKey = $env:PDC_SIGNING_KEY
|
||||
}
|
||||
|
||||
$stageRoot = Join-Path $PWD "pdc-stage"
|
||||
$payloadRoot = Join-Path $PWD "payload-artifacts"
|
||||
$installerRoot = Join-Path $PWD "installer-artifacts"
|
||||
$outRoot = Join-Path $PWD "pdc-output"
|
||||
$client = Join-Path $PWD "pdcc/PhainonDistributionCenter.Client"
|
||||
$config = Join-Path $PWD "pdc-work/phainon.resolved.yml"
|
||||
|
||||
if (Test-Path $stageRoot) {
|
||||
Remove-Item -LiteralPath $stageRoot -Recurse -Force
|
||||
}
|
||||
if (Test-Path $outRoot) {
|
||||
Remove-Item -LiteralPath $outRoot -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $stageRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $outRoot -Force | Out-Null
|
||||
|
||||
$payloadArtifacts = Get-ChildItem -LiteralPath $payloadRoot -Directory
|
||||
if (-not $payloadArtifacts) {
|
||||
throw "No payload artifacts were downloaded."
|
||||
}
|
||||
|
||||
$installerArtifacts = Get-ChildItem -LiteralPath $installerRoot -Directory
|
||||
if (-not $installerArtifacts) {
|
||||
throw "No installer artifacts were downloaded."
|
||||
}
|
||||
|
||||
foreach ($installerArtifact in $installerArtifacts) {
|
||||
$stagedInstallerDir = Join-Path $stageRoot "installers/$($installerArtifact.Name)"
|
||||
./scripts/Prepare-PdccOut.ps1 -SourceDir $installerArtifact.FullName -OutputDir $stagedInstallerDir
|
||||
}
|
||||
|
||||
foreach ($payloadArtifact in $payloadArtifacts) {
|
||||
$platformKey = $payloadArtifact.Name -replace '^app-payload-', ''
|
||||
$stagedPayloadDir = Join-Path $stageRoot "payloads/$platformKey"
|
||||
./scripts/Prepare-PdccOut.ps1 -SourceDir $payloadArtifact.FullName -OutputDir $stagedPayloadDir
|
||||
|
||||
$subChannel = ($platformKey -replace '-', '_') + "_release_folderClassic"
|
||||
$env:PDC_SUBCHANNEL = $subChannel
|
||||
|
||||
Push-Location $stagedPayloadDir
|
||||
try {
|
||||
& $client $config Publish $env:PRIMARY_VERSION $env:VERSION (Join-Path $outRoot "published/$platformKey")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "PDCC Publish failed for $platformKey."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path (Join-Path $stageRoot "installers")) {
|
||||
aws --endpoint-url "$env:S3_ENDPOINT" s3 sync (Join-Path $stageRoot "installers") "s3://$env:S3_BUCKET/lanmountain/update/installers/" --only-show-errors
|
||||
}
|
||||
|
||||
- name: Upload PDC Assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pdc-assets
|
||||
path: |
|
||||
pdc-output/published/**
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
github-release:
|
||||
needs: [ prepare, build-windows, build-linux, build-macos, publish-pdc ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
- name: Download installer artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: release-*
|
||||
path: artifacts/installers
|
||||
pattern: installer-*
|
||||
|
||||
- name: Download PDC artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/pdc
|
||||
pattern: pdc-assets
|
||||
|
||||
- name: List artifacts structure
|
||||
run: |
|
||||
@@ -830,10 +906,8 @@ jobs:
|
||||
run: |
|
||||
echo "Organizing artifacts..."
|
||||
mkdir -p release-files
|
||||
# Copy installers and packages
|
||||
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
||||
# Copy 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/ \;
|
||||
find artifacts/installers -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
|
||||
find artifacts/pdc -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
|
||||
echo ""
|
||||
echo "Files ready for release:"
|
||||
ls -lh release-files/ || echo "No files found in release-files"
|
||||
@@ -867,12 +941,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)
|
||||
|
||||
13
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
13
.trae/specs/pdc-incremental-migration/checklist.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Checklist
|
||||
|
||||
- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
|
||||
- [ ] `release.yml` uploads app payload artifacts for PDCC.
|
||||
- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
|
||||
- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
|
||||
- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
|
||||
- [ ] Host can persist PDC payload into launcher incoming directory.
|
||||
- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
|
||||
- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
|
||||
- [ ] CI run attached proving all release matrix jobs pass.
|
||||
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
|
||||
- [ ] Rollback verification report attached.
|
||||
44
.trae/specs/pdc-incremental-migration/spec.md
Normal file
44
.trae/specs/pdc-incremental-migration/spec.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# PDC Incremental Update Migration
|
||||
|
||||
## Goal
|
||||
|
||||
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
|
||||
|
||||
## Stage 1 (Completed)
|
||||
|
||||
- Release workflow removed VeloPack-based release packaging.
|
||||
- Signed FileMap path was restored as an interim release mechanism.
|
||||
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
|
||||
|
||||
## Stage 2 (Current Implementation Target)
|
||||
|
||||
- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
|
||||
- Promote PDC-distributed FileMap/object-repo as the primary incremental path.
|
||||
- Keep GitHub Release installers and metadata as parallel distribution.
|
||||
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
|
||||
- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
|
||||
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
|
||||
|
||||
Expected S3 layout:
|
||||
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
|
||||
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
|
||||
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
|
||||
- `lanmountain/update/installers/<platform>/<arch>/*`
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `release.yml` includes PDCC publish steps and no Velopack steps.
|
||||
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
|
||||
- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
|
||||
- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
|
||||
- Launcher can apply both:
|
||||
- legacy signed `files.json + update.zip`
|
||||
- PDC FileMap object-repo payload.
|
||||
- Rollback semantics remain unchanged.
|
||||
|
||||
## Deprecated Notes
|
||||
|
||||
- The following interim outputs are compatibility-only (not the long-term primary path):
|
||||
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
|
||||
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
|
||||
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
|
||||
15
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
15
.trae/specs/pdc-incremental-migration/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Remove VeloPack packaging from release workflow.
|
||||
- [x] Keep signed FileMap path as interim compatibility fallback.
|
||||
- [x] Remove launcher/runtime Velopack branching.
|
||||
- [ ] Add `phainon.yml` for PDCC publish configuration.
|
||||
- [ ] Add PDCC installation + publish steps in `release.yml`.
|
||||
- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
|
||||
- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
|
||||
- [ ] Mirror installers to `lanmountain/update/installers/<platform>/<arch>/`.
|
||||
- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
|
||||
- [ ] Add PDC payload model into host update check result.
|
||||
- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
|
||||
- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
|
||||
- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.
|
||||
@@ -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).
|
||||
|
||||
@@ -214,14 +214,12 @@ public partial class App : Application
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
|
||||
// TODO: 从配置读取 GitHub 仓库信息
|
||||
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
|
||||
|
||||
coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
updateCheckService,
|
||||
new PluginInstallerService());
|
||||
|
||||
result = await coordinator.RunAsync(splashWindow).ConfigureAwait(false);
|
||||
|
||||
@@ -9,6 +9,11 @@ namespace LanMountainDesktop.Launcher;
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(SignedFileMap))]
|
||||
[JsonSerializable(typeof(UpdateFileEntry))]
|
||||
[JsonSerializable(typeof(PdcUpdateMetadata))]
|
||||
[JsonSerializable(typeof(PdcFileMap))]
|
||||
[JsonSerializable(typeof(PdcComponentEntry))]
|
||||
[JsonSerializable(typeof(PdcFileEntry))]
|
||||
[JsonSerializable(typeof(PdcHashDescriptor))]
|
||||
[JsonSerializable(typeof(SnapshotMetadata))]
|
||||
[JsonSerializable(typeof(AppVersionInfo))]
|
||||
[JsonSerializable(typeof(StartupProgressMessage))]
|
||||
@@ -20,7 +25,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;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
-----BEGIN RSA PUBLIC KEY-----
|
||||
MIIBCgKCAQEAxPqgXsrnG8Re0kV4HBb+x61HQpjCahJoilzKvvlnXanuGtGxbjZT
|
||||
B+kMzmPUwyx8gt1fcaBNoKPwpwP0UZRWjvJDZQ++5ex7LGGw0YRWtJmeeigS17YI
|
||||
90vEfX3xQ5InJoBKnndsRy2a742chE6YwHGrJ4b107ZJ+zd26FmokQS47Uzay3go
|
||||
msbQHdehwCdCiW1mh8YFDm0xny+PYoYZkGXiDOYY0nvg4yJ/BG2fQkkC5TNizr0l
|
||||
YcE3RrMRcyJB7zU3jN1QnjHIvIvwfCOXaLdcXtxgQFRv45sYpmj9amNjuurM5iUa
|
||||
20Mk1ilYBuLxqe6P9C8DakZY/akVxpzxrQIDAQAB
|
||||
-----END RSA PUBLIC KEY-----
|
||||
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
|
||||
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
|
||||
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
|
||||
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
|
||||
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
|
||||
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
|
||||
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
|
||||
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
|
||||
FQiGowgqx0l5AgMBAAE=
|
||||
-----END RSA PUBLIC KEY-----
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -53,3 +53,92 @@ internal sealed class UpdateApplyResult
|
||||
|
||||
public string? RolledBackTo { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PdcUpdateMetadata
|
||||
{
|
||||
public string? DistributionId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? SubChannel { get; set; }
|
||||
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? FileMapPath { get; set; }
|
||||
|
||||
public string? FileMapSignaturePath { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PdcFileMap
|
||||
{
|
||||
public string? DistributionId { get; set; }
|
||||
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
|
||||
public List<PdcComponentEntry> Components { get; set; } = [];
|
||||
|
||||
public List<PdcFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PdcComponentEntry
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
|
||||
public List<PdcFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PdcFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? Action { get; set; } = "replace";
|
||||
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string? ObjectUrl { get; set; }
|
||||
|
||||
public string? ObjectPath { get; set; }
|
||||
|
||||
public string? ObjectKey { get; set; }
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
public string? Sha512 { get; set; }
|
||||
|
||||
public string? Sha512Base64 { get; set; }
|
||||
|
||||
public byte[]? Sha512Bytes { get; set; }
|
||||
|
||||
public PdcHashDescriptor? Hash { get; set; }
|
||||
|
||||
public Dictionary<string, string> Metadata { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PdcHashDescriptor
|
||||
{
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
public string? Value { get; set; }
|
||||
|
||||
public byte[]? Bytes { get; set; }
|
||||
}
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -22,7 +22,6 @@ internal sealed class LauncherFlowCoordinator
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly UpdateEngineService _updateEngine;
|
||||
private readonly UpdateCheckService _updateCheckService;
|
||||
private readonly PluginInstallerService _pluginInstallerService;
|
||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||
|
||||
@@ -31,14 +30,12 @@ internal sealed class LauncherFlowCoordinator
|
||||
DeploymentLocator deploymentLocator,
|
||||
OobeStateService oobeStateService,
|
||||
UpdateEngineService updateEngine,
|
||||
UpdateCheckService updateCheckService,
|
||||
PluginInstallerService pluginInstallerService)
|
||||
{
|
||||
_context = context;
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_oobeStateService = oobeStateService;
|
||||
_updateEngine = updateEngine;
|
||||
_updateCheckService = updateCheckService;
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
||||
}
|
||||
|
||||
@@ -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() ?? [];
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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; } = "stcn";
|
||||
|
||||
public int UpdateDownloadThreads { get; set; } = 4;
|
||||
|
||||
|
||||
@@ -34,7 +34,17 @@ public sealed record UpdateCheckResult(
|
||||
GitHubReleaseInfo? Release,
|
||||
GitHubReleaseAsset? PreferredAsset,
|
||||
string? ErrorMessage,
|
||||
bool ForceMode = false);
|
||||
bool ForceMode = false,
|
||||
PdcUpdatePayload? PdcPayload = null);
|
||||
|
||||
public sealed record PdcUpdatePayload(
|
||||
string DistributionId,
|
||||
string ChannelId,
|
||||
string SubChannel,
|
||||
string? FileMapJson,
|
||||
string? FileMapSignature,
|
||||
string? FileMapJsonUrl,
|
||||
string? FileMapSignatureUrl);
|
||||
|
||||
public sealed record UpdateDownloadResult(
|
||||
bool Success,
|
||||
|
||||
580
LanMountainDesktop/Services/PdcReleaseUpdateService.cs
Normal file
580
LanMountainDesktop/Services/PdcReleaseUpdateService.cs
Normal file
@@ -0,0 +1,580 @@
|
||||
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);
|
||||
var pdcPayload = ResolvePdcPayload(distributionNode, distributionId, channelId, subChannel);
|
||||
if (assets.Count == 0 && !HasPdcPayload(pdcPayload))
|
||||
{
|
||||
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);
|
||||
var preferredAsset = SelectPreferredInstallerAsset(assets);
|
||||
|
||||
return new UpdateCheckResult(
|
||||
Success: true,
|
||||
IsUpdateAvailable: true,
|
||||
CurrentVersionText: normalizedCurrentVersionText,
|
||||
LatestVersionText: latestVersionText,
|
||||
Release: release,
|
||||
PreferredAsset: preferredAsset,
|
||||
ErrorMessage: null,
|
||||
ForceMode: isForce,
|
||||
PdcPayload: pdcPayload);
|
||||
}
|
||||
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 PdcUpdatePayload ResolvePdcPayload(
|
||||
JsonElement distributionNode,
|
||||
string distributionId,
|
||||
string channelId,
|
||||
string subChannel)
|
||||
{
|
||||
var fileMapJson = ReadString(distributionNode, "fileMapJson");
|
||||
var fileMapSignature = ReadString(distributionNode, "fileMapSignature");
|
||||
var fileMapJsonUrl = ReadString(distributionNode, "fileMapJsonUrl")
|
||||
?? ReadString(distributionNode, "fileMapUrl")
|
||||
?? ReadString(distributionNode, "manifestUrl");
|
||||
var fileMapSignatureUrl = ReadString(distributionNode, "fileMapSignatureUrl")
|
||||
?? ReadString(distributionNode, "signatureUrl");
|
||||
return new PdcUpdatePayload(
|
||||
DistributionId: distributionId,
|
||||
ChannelId: channelId,
|
||||
SubChannel: subChannel,
|
||||
FileMapJson: fileMapJson,
|
||||
FileMapSignature: fileMapSignature,
|
||||
FileMapJsonUrl: fileMapJsonUrl,
|
||||
FileMapSignatureUrl: fileMapSignatureUrl);
|
||||
}
|
||||
|
||||
private static bool HasPdcPayload(PdcUpdatePayload payload)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(payload.FileMapJson)
|
||||
|| !string.IsNullOrWhiteSpace(payload.FileMapJsonUrl);
|
||||
}
|
||||
|
||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||
{
|
||||
if (assets is null || assets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var archToken = RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.X86 => "x86",
|
||||
_ => "x64"
|
||||
};
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".exe", ".msi", archToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".deb", ".rpm", "x64")))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
var archToken = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64";
|
||||
return assets
|
||||
.Select(asset => (Asset: asset, Score: ScoreInstallerAsset(asset.Name, ".dmg", ".pkg", archToken)))
|
||||
.OrderByDescending(x => x.Score)
|
||||
.FirstOrDefault(x => x.Score > 0)
|
||||
.Asset;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ScoreInstallerAsset(string name, string ext1, string ext2, string archToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
if (name.EndsWith(ext1, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 200;
|
||||
}
|
||||
else if (name.EndsWith(ext2, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 160;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (name.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("installer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 50;
|
||||
}
|
||||
|
||||
if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if (name.Contains("portable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
|
||||
{
|
||||
if (metadataNode.ValueKind != JsonValueKind.Object ||
|
||||
!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];
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,7 @@ public interface IUpdateSettingsService
|
||||
void Save(UpdateSettingsState state);
|
||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||
Task<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
|
||||
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
GitHubReleaseAsset asset,
|
||||
string destinationFilePath,
|
||||
|
||||
@@ -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,19 @@ 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 async Task<PdcUpdatePayload?> GetPdcUpdatePayloadAsync(
|
||||
Version currentVersion,
|
||||
bool includePrerelease,
|
||||
bool isForce = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = isForce
|
||||
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||
return result.Success ? result.PdcPayload : null;
|
||||
}
|
||||
|
||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||
@@ -849,7 +862,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 +879,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 +890,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,10 @@ public static class UpdateSettingsValues
|
||||
public const string ModeDownloadThenConfirm = "download_then_confirm";
|
||||
public const string ModeSilentOnExit = "silent_on_exit";
|
||||
|
||||
// NOTE: keep constant name for compatibility with existing call sites.
|
||||
public const string DownloadSourcePdc = "stcn";
|
||||
public const string DownloadSourceStcn = DownloadSourcePdc;
|
||||
public const string LegacyDownloadSourcePdc = "pdc";
|
||||
public const string DownloadSourceGitHub = "github";
|
||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||
|
||||
@@ -51,9 +55,28 @@ public static class UpdateSettingsValues
|
||||
|
||||
public static string NormalizeDownloadSource(string? value)
|
||||
{
|
||||
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
|
||||
? DownloadSourceGhProxy
|
||||
: DownloadSourceGitHub;
|
||||
if (string.Equals(value, LegacyDownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DownloadSourceStcn;
|
||||
}
|
||||
|
||||
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 STCN(PDC/S3). Runtime will fallback to GitHub if STCN is unavailable.
|
||||
return DownloadSourceStcn;
|
||||
}
|
||||
|
||||
public static int NormalizeDownloadThreads(int value)
|
||||
|
||||
@@ -5,6 +5,11 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
@@ -52,7 +57,20 @@ 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 IncomingObjectsDirectoryName = "objects";
|
||||
private const string SignedFileMapName = "files.json";
|
||||
private const string SignedFileMapSignatureName = "files.json.sig";
|
||||
private const string UpdateArchiveName = "update.zip";
|
||||
private const string PdcFileMapName = "pdc-filemap.json";
|
||||
private const string PdcFileMapSignatureName = "pdc-filemap.sig";
|
||||
private const string PdcUpdateStateName = "pdc-update.json";
|
||||
|
||||
private static readonly HttpClient PdcHttpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
private static readonly ResumableDownloadService PdcDownloadService = new(PdcHttpClient);
|
||||
|
||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
@@ -78,8 +96,13 @@ public sealed class UpdateWorkflowService
|
||||
return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
|
||||
}
|
||||
|
||||
public static string GetLauncherIncomingObjectsDirectory()
|
||||
{
|
||||
return Path.Combine(GetLauncherIncomingDirectory(), IncomingObjectsDirectoryName);
|
||||
}
|
||||
|
||||
/// <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 +111,21 @@ 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 _);
|
||||
}
|
||||
|
||||
public static bool IsDeltaUpdateAvailable(UpdateCheckResult checkResult)
|
||||
{
|
||||
if (checkResult.PdcPayload is not null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return checkResult.Release is not null && IsDeltaUpdateAvailable(checkResult.Release);
|
||||
}
|
||||
|
||||
/// <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,
|
||||
@@ -103,16 +134,26 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null)
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
||||
}
|
||||
|
||||
var releasesFeedAsset = checkResult.Release.Assets.FirstOrDefault(a =>
|
||||
string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase));
|
||||
if (releasesFeedAsset is null)
|
||||
if (checkResult.PdcPayload is null && checkResult.Release is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Release does not contain releases.win.json.");
|
||||
return new UpdateDownloadResult(false, null, "No update payload is available for delta download.");
|
||||
}
|
||||
|
||||
if (checkResult.PdcPayload is not null)
|
||||
{
|
||||
return await DownloadPdcDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
||||
}
|
||||
|
||||
var release = checkResult.Release;
|
||||
if (release is null ||
|
||||
!TryResolveDeltaAssets(release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
|
||||
}
|
||||
|
||||
var incomingDir = GetLauncherIncomingDirectory();
|
||||
@@ -130,29 +171,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 +191,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,34 +215,191 @@ 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
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
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);
|
||||
}
|
||||
|
||||
private async Task<UpdateDownloadResult> DownloadPdcDeltaUpdateAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = checkResult.PdcPayload;
|
||||
if (payload is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "PDC payload is missing.");
|
||||
}
|
||||
|
||||
var incomingDir = GetLauncherIncomingDirectory();
|
||||
var objectsDir = GetLauncherIncomingObjectsDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(incomingDir);
|
||||
Directory.CreateDirectory(objectsDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var downloadThreads = Math.Max(1, state.UpdateDownloadThreads);
|
||||
var fileMapPath = Path.Combine(incomingDir, PdcFileMapName);
|
||||
var signaturePath = Path.Combine(incomingDir, PdcFileMapSignatureName);
|
||||
var updateStatePath = Path.Combine(incomingDir, PdcUpdateStateName);
|
||||
|
||||
var fileMapJson = await EnsurePdcTextResourceAsync(
|
||||
payload.FileMapJson,
|
||||
payload.FileMapJsonUrl,
|
||||
fileMapPath,
|
||||
cancellationToken);
|
||||
|
||||
var fileMapSignature = await EnsurePdcTextResourceAsync(
|
||||
payload.FileMapSignature,
|
||||
payload.FileMapSignatureUrl,
|
||||
signaturePath,
|
||||
cancellationToken);
|
||||
|
||||
var downloadEntries = ParsePdcDownloadEntries(fileMapJson);
|
||||
if (downloadEntries.Count == 0)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "PDC file map does not contain downloadable objects.");
|
||||
}
|
||||
|
||||
var expectedObjectCount = downloadEntries.Count;
|
||||
var completedItems = 2;
|
||||
progress?.Report(expectedObjectCount == 0 ? 1d : (double)completedItems / (expectedObjectCount + 2));
|
||||
|
||||
var objectResults = new List<PdcDownloadedObjectInfo>(expectedObjectCount);
|
||||
var objectTargets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var totalSteps = expectedObjectCount + 2;
|
||||
|
||||
foreach (var entry in downloadEntries)
|
||||
{
|
||||
if (!objectTargets.Add(entry.ObjectHashHex))
|
||||
{
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
continue;
|
||||
}
|
||||
|
||||
var destinationPath = GetPdcObjectDestinationPath(objectsDir, entry.ObjectHashHex);
|
||||
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
}
|
||||
|
||||
if (File.Exists(destinationPath))
|
||||
{
|
||||
var existingHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken);
|
||||
if (string.Equals(existingHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var downloadOptions = new DownloadOptions(MaxParallelSegments: downloadThreads);
|
||||
var downloadResult = await PdcDownloadService.DownloadAsync(
|
||||
entry.DownloadUrl,
|
||||
destinationPath,
|
||||
downloadOptions,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"Failed to download PDC object {entry.RelativePath}: {downloadResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
var actualHash = await ComputeFileSha512HexAsync(destinationPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(actualHash) &&
|
||||
!string.Equals(actualHash, entry.ObjectHashHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"PDC object hash mismatch for {entry.RelativePath}. Expected: {entry.ObjectHashHex}, Actual: {actualHash}");
|
||||
}
|
||||
|
||||
objectResults.Add(new PdcDownloadedObjectInfo(entry.ComponentId, entry.RelativePath, entry.DownloadUrl, entry.ObjectHashHex, destinationPath));
|
||||
completedItems++;
|
||||
progress?.Report((double)completedItems / totalSteps);
|
||||
}
|
||||
|
||||
var updateState = new PdcUpdateState(
|
||||
checkResult.LatestVersionText,
|
||||
payload.DistributionId,
|
||||
payload.ChannelId,
|
||||
payload.SubChannel,
|
||||
fileMapPath,
|
||||
signaturePath,
|
||||
objectsDir,
|
||||
DateTimeOffset.UtcNow,
|
||||
fileMapJson,
|
||||
fileMapSignature,
|
||||
objectResults);
|
||||
|
||||
await File.WriteAllTextAsync(updateStatePath, JsonSerializer.Serialize(updateState, UpdateJsonOptions), cancellationToken);
|
||||
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = updateStatePath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
|
||||
progress?.Report(1d);
|
||||
AppLogger.Info("UpdateWorkflow", $"PDC update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
||||
return new UpdateDownloadResult(true, updateStatePath, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", "Failed to download PDC incremental payload.", ex);
|
||||
return new UpdateDownloadResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions UpdateJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the pending update is managed by Launcher incoming payload.
|
||||
/// </summary>
|
||||
@@ -224,11 +412,321 @@ 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 the local manifest or incoming directory path.
|
||||
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.EndsWith(PdcUpdateStateName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.EndsWith(PdcFileMapName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.EndsWith(PdcFileMapSignatureName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetPdcObjectDestinationPath(string objectsDirectory, string objectHashHex)
|
||||
{
|
||||
var normalizedHash = objectHashHex.Trim().ToLowerInvariant();
|
||||
var shard = normalizedHash.Length >= 2 ? normalizedHash[..2] : normalizedHash;
|
||||
return Path.Combine(objectsDirectory, shard, normalizedHash);
|
||||
}
|
||||
|
||||
private static async Task<string> EnsurePdcTextResourceAsync(
|
||||
string? inlineContent,
|
||||
string? sourceUrl,
|
||||
string destinationPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(inlineContent))
|
||||
{
|
||||
await File.WriteAllTextAsync(destinationPath, inlineContent, cancellationToken);
|
||||
return inlineContent;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceUrl))
|
||||
{
|
||||
throw new InvalidOperationException("PDC payload does not contain a file map source.");
|
||||
}
|
||||
|
||||
var downloadResult = await PdcDownloadService.DownloadAsync(
|
||||
sourceUrl,
|
||||
destinationPath,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (!downloadResult.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to download PDC file map resource: {downloadResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
return await File.ReadAllTextAsync(destinationPath, cancellationToken);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PdcDownloadEntry> ParsePdcDownloadEntries(string fileMapJson)
|
||||
{
|
||||
var entries = new List<PdcDownloadEntry>();
|
||||
if (string.IsNullOrWhiteSpace(fileMapJson))
|
||||
{
|
||||
return entries;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(fileMapJson);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (!TryGetPropertyIgnoreCase(root, "components", out var componentsNode) ||
|
||||
componentsNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return entries;
|
||||
}
|
||||
|
||||
foreach (var component in componentsNode.EnumerateObject())
|
||||
{
|
||||
if (component.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryGetPropertyIgnoreCase(component.Value, "files", out var filesNode) ||
|
||||
filesNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var fileEntry in filesNode.EnumerateObject())
|
||||
{
|
||||
if (fileEntry.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var downloadUrl = ReadStringIgnoreCase(fileEntry.Value, "archivedownloadurl")
|
||||
?? ReadStringIgnoreCase(fileEntry.Value, "downloadurl")
|
||||
?? ReadStringIgnoreCase(fileEntry.Value, "url");
|
||||
var hashBytes = ReadByteArrayIgnoreCase(fileEntry.Value, "archivesha512")
|
||||
?? ReadByteArrayIgnoreCase(fileEntry.Value, "filesha512");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(downloadUrl) || hashBytes is null || hashBytes.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
entries.Add(new PdcDownloadEntry(
|
||||
component.Name,
|
||||
fileEntry.Name,
|
||||
downloadUrl,
|
||||
hashHex));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static async Task<string?> ComputeFileSha512HexAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var hashBytes = await SHA512.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (node.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in node.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ReadStringIgnoreCase(JsonElement node, string propertyName)
|
||||
{
|
||||
return TryGetPropertyIgnoreCase(node, propertyName, out var value)
|
||||
? value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: value.ToString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static byte[]? ReadByteArrayIgnoreCase(JsonElement node, string propertyName)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReadByteArray(value);
|
||||
}
|
||||
|
||||
private static byte[]? ReadByteArray(JsonElement value)
|
||||
{
|
||||
switch (value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.String:
|
||||
{
|
||||
var text = value.GetString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (IsHexString(text))
|
||||
{
|
||||
try
|
||||
{
|
||||
return Convert.FromHexString(text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through to base64
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
case JsonValueKind.Array:
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
foreach (var item in value.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetInt32(out var number) || number is < byte.MinValue or > byte.MaxValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bytes.Add((byte)number);
|
||||
}
|
||||
|
||||
return bytes.ToArray();
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHexString(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length % 2 != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!Uri.IsHexDigit(ch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record PdcDownloadEntry(
|
||||
string ComponentId,
|
||||
string RelativePath,
|
||||
string DownloadUrl,
|
||||
string ObjectHashHex);
|
||||
|
||||
private sealed record PdcDownloadedObjectInfo(
|
||||
string ComponentId,
|
||||
string RelativePath,
|
||||
string SourceUrl,
|
||||
string ObjectHashHex,
|
||||
string LocalPath);
|
||||
|
||||
private sealed record PdcUpdateState(
|
||||
string VersionText,
|
||||
string DistributionId,
|
||||
string ChannelId,
|
||||
string SubChannel,
|
||||
string FileMapPath,
|
||||
string FileMapSignaturePath,
|
||||
string ObjectsDirectory,
|
||||
DateTimeOffset DownloadedAtUtc,
|
||||
string FileMapJson,
|
||||
string FileMapSignature,
|
||||
IReadOnlyList<PdcDownloadedObjectInfo> Objects);
|
||||
|
||||
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();
|
||||
@@ -278,6 +776,11 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (checkResult.PdcPayload is not null)
|
||||
{
|
||||
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
||||
}
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
||||
@@ -316,9 +819,9 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
@@ -334,6 +837,12 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (checkResult.PdcPayload is not null)
|
||||
{
|
||||
ClearPendingUpdate();
|
||||
return await DownloadDeltaUpdateAsync(checkResult, progress, cancellationToken);
|
||||
}
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
||||
@@ -377,9 +886,9 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release?.PublishedAt is DateTimeOffset publishedAt && publishedAt != DateTimeOffset.MinValue
|
||||
? publishedAt.ToUnixTimeMilliseconds()
|
||||
: null,
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = result.ActualHash
|
||||
});
|
||||
@@ -400,9 +909,27 @@ public sealed class UpdateWorkflowService
|
||||
|
||||
if (!File.Exists(pending.InstallerPath))
|
||||
{
|
||||
if (IsPendingDeltaUpdate())
|
||||
{
|
||||
var pdcUpdatePath = pending.InstallerPath;
|
||||
var pdcFileMapPath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PdcFileMapName);
|
||||
var pdcSignaturePath = Path.Combine(Path.GetDirectoryName(pdcUpdatePath) ?? string.Empty, PdcFileMapSignatureName);
|
||||
if (File.Exists(pdcUpdatePath) && File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath))
|
||||
{
|
||||
return new UpdateVerifyResult(true, true, null, null, null);
|
||||
}
|
||||
|
||||
return new UpdateVerifyResult(false, false, null, null, "PDC update payload is incomplete.");
|
||||
}
|
||||
|
||||
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
|
||||
}
|
||||
|
||||
if (IsPendingDeltaUpdate())
|
||||
{
|
||||
return new UpdateVerifyResult(true, true, null, null, null);
|
||||
}
|
||||
|
||||
var expectedHash = pending.Sha256;
|
||||
var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
|
||||
|
||||
@@ -434,7 +961,7 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
// Always check for updates on startup (removed AutoCheckUpdates check)
|
||||
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
|
||||
if (!result.Success || !result.IsUpdateAvailable || result.Release is null)
|
||||
if (!result.Success || !result.IsUpdateAvailable || (result.Release is null && result.PdcPayload is null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -446,7 +973,7 @@ public sealed class UpdateWorkflowService
|
||||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Prefer delta update if available (smaller download, faster)
|
||||
if (IsDeltaUpdateAvailable(result.Release))
|
||||
if (IsDeltaUpdateAvailable(result))
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package.");
|
||||
await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken);
|
||||
@@ -470,6 +997,14 @@ public sealed class UpdateWorkflowService
|
||||
|
||||
public UpdateInstallerLaunchResult LaunchPendingInstallerNow()
|
||||
{
|
||||
if (IsPendingDeltaUpdate())
|
||||
{
|
||||
var launchResult = LaunchLauncherForApplyUpdate();
|
||||
return launchResult
|
||||
? new UpdateInstallerLaunchResult(true, false, null)
|
||||
: new UpdateInstallerLaunchResult(false, false, "Failed to launch updater for incremental update.");
|
||||
}
|
||||
|
||||
return LaunchPendingInstaller(silent: false, exitApplicationAfterLaunch: true);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
23
phainon.yml
Normal file
23
phainon.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Phainon Distribution Center Client Configuration
|
||||
name: "LanMountainDesktop"
|
||||
components:
|
||||
app:
|
||||
allowDiffUpdate: true
|
||||
root: "app-$(version)/"
|
||||
includes:
|
||||
- "**"
|
||||
launcher:
|
||||
root: ""
|
||||
includes:
|
||||
- "**"
|
||||
excludes:
|
||||
- "app*/**"
|
||||
- "files*.json"
|
||||
- "files*.json.sig"
|
||||
- "update*.zip"
|
||||
variables:
|
||||
number: 0
|
||||
fileRepoRoot: "__FILE_REPO_ROOT__"
|
||||
archiveRoot: "__ARCHIVE_ROOT__"
|
||||
bucketKeyRoot: "lanmountain/update/repo/"
|
||||
archiveBucketKeyRoot: "lanmountain/update/installers/"
|
||||
@@ -1,105 +1,165 @@
|
||||
# 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
|
||||
)
|
||||
|
||||
$separator = [System.IO.Path]::DirectorySeparatorChar
|
||||
$altSeparator = [System.IO.Path]::AltDirectorySeparatorChar
|
||||
|
||||
$root = [System.IO.Path]::GetFullPath($RootDir).Replace($altSeparator, $separator).TrimEnd($separator)
|
||||
$path = [System.IO.Path]::GetFullPath($FullPath).Replace($altSeparator, $separator)
|
||||
|
||||
$comparison = if ($separator -eq '\') {
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
}
|
||||
else {
|
||||
[System.StringComparison]::Ordinal
|
||||
}
|
||||
|
||||
$rootWithSeparator = "$root$separator"
|
||||
if ($path.StartsWith($rootWithSeparator, $comparison)) {
|
||||
$relative = $path.Substring($rootWithSeparator.Length)
|
||||
}
|
||||
elseif ($path.Equals($root, $comparison)) {
|
||||
$relative = ""
|
||||
}
|
||||
else {
|
||||
throw "File path '$path' is not under root '$root'."
|
||||
}
|
||||
|
||||
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 = $false)]
|
||||
[AllowEmptyCollection()]
|
||||
[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 +167,9 @@ foreach ($path in $currentManifest.Keys) {
|
||||
ArchivePath = $path
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# 新增文件
|
||||
$changedFiles += @{
|
||||
}
|
||||
else {
|
||||
$changedFiles += [ordered]@{
|
||||
Path = $path
|
||||
Action = "add"
|
||||
Sha256 = $currentFile.Sha256
|
||||
@@ -119,104 +179,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"
|
||||
|
||||
101
scripts/Install-Pdcc.ps1
Normal file
101
scripts/Install-Pdcc.ps1
Normal file
@@ -0,0 +1,101 @@
|
||||
param(
|
||||
[string]$Repository = "ClassIsland/PhainonDistributionCenter",
|
||||
[string]$AssetName = "out_app_linux_x64.zip",
|
||||
[string]$Version = "",
|
||||
[string]$OutputDir = (Join-Path $PSScriptRoot "..\pdcc")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Repository)) {
|
||||
throw "Repository is required."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($AssetName)) {
|
||||
throw "AssetName is required."
|
||||
}
|
||||
|
||||
$OutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||||
if (-not (Test-Path -LiteralPath $OutputDir)) {
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$clientName = if ($env:OS -eq "Windows_NT") { "PhainonDistributionCenter.Client.exe" } else { "PhainonDistributionCenter.Client" }
|
||||
$clientPath = Join-Path $OutputDir $clientName
|
||||
if (Test-Path -LiteralPath $clientPath) {
|
||||
Write-Host "PDCC client already installed at $clientPath"
|
||||
return
|
||||
}
|
||||
|
||||
$releaseTag = $Version
|
||||
if ([string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||
$releaseTag = $env:PDC_CLIENT_VERSION
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||
$releaseTag = $env:PDCC_VERSION
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||
$releaseTag = $env:PDCC_version
|
||||
}
|
||||
|
||||
$tempDir = Join-Path $env:RUNNER_TEMP "pdcc-install"
|
||||
if (Test-Path -LiteralPath $tempDir) {
|
||||
Remove-Item -LiteralPath $tempDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
$zipPath = Join-Path $tempDir $AssetName
|
||||
|
||||
if (Get-Command gh -ErrorAction SilentlyContinue) {
|
||||
Write-Host "Downloading PDCC via gh release download from $Repository ..."
|
||||
$ghArgs = @("release", "download", "--repo", $Repository, "--pattern", $AssetName, "--dir", $tempDir, "--clobber")
|
||||
if (-not [string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||
$ghArgs = @("release", "download", $releaseTag, "--repo", $Repository, "--pattern", $AssetName, "--dir", $tempDir, "--clobber")
|
||||
}
|
||||
|
||||
& gh @ghArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "gh release download failed for $Repository/$AssetName."
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ([string]::IsNullOrWhiteSpace($releaseTag)) {
|
||||
throw "PDCC_VERSION is required when gh is unavailable."
|
||||
}
|
||||
|
||||
$downloadUrl = "https://github.com/$Repository/releases/download/$releaseTag/$AssetName"
|
||||
Write-Host "Downloading PDCC from $downloadUrl ..."
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath
|
||||
}
|
||||
|
||||
$extractDir = Join-Path $tempDir "extract"
|
||||
if (Test-Path -LiteralPath $extractDir) {
|
||||
Remove-Item -LiteralPath $extractDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
||||
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
|
||||
|
||||
$copied = $false
|
||||
foreach ($file in Get-ChildItem -LiteralPath $extractDir -Recurse -File) {
|
||||
if ($file.Name -ieq $clientName) {
|
||||
Copy-Item -LiteralPath $file.FullName -Destination $clientPath -Force
|
||||
$copied = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $copied) {
|
||||
throw "PDCC client executable not found in downloaded archive."
|
||||
}
|
||||
|
||||
if ($IsLinux) {
|
||||
try {
|
||||
chmod +x $clientPath | Out-Null
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "PDCC installed to $clientPath"
|
||||
59
scripts/Prepare-PdccOut.ps1
Normal file
59
scripts/Prepare-PdccOut.ps1
Normal file
@@ -0,0 +1,59 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SourceDir,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
[string]$PlatformKey = "",
|
||||
|
||||
[string[]]$InstallerFiles = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$SourceDir = [System.IO.Path]::GetFullPath($SourceDir)
|
||||
$OutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SourceDir)) {
|
||||
throw "Source directory not found: $SourceDir"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $OutputDir) {
|
||||
Remove-Item -LiteralPath $OutputDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
$payloadRoot = if ([string]::IsNullOrWhiteSpace($PlatformKey)) {
|
||||
$OutputDir
|
||||
} else {
|
||||
Join-Path $OutputDir $PlatformKey
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $payloadRoot -Force | Out-Null
|
||||
Get-ChildItem -LiteralPath $SourceDir -Force | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $payloadRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if ($InstallerFiles.Count -gt 0) {
|
||||
$installerRoot = Join-Path $OutputDir "installers"
|
||||
if (-not (Test-Path -LiteralPath $installerRoot)) {
|
||||
New-Item -ItemType Directory -Path $installerRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
foreach ($installer in $InstallerFiles) {
|
||||
if ([string]::IsNullOrWhiteSpace($installer)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$installerPath = [System.IO.Path]::GetFullPath($installer)
|
||||
if (-not (Test-Path -LiteralPath $installerPath)) {
|
||||
throw "Installer file not found: $installerPath"
|
||||
}
|
||||
|
||||
$targetPath = Join-Path $installerRoot ([System.IO.Path]::GetFileName($installerPath))
|
||||
Copy-Item -LiteralPath $installerPath -Destination $targetPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Prepared PDCC staging directory: $payloadRoot"
|
||||
@@ -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