chore: migrate release pipeline to signed filemap and wire rainyun s3

This commit is contained in:
lincube
2026-04-20 07:48:53 +08:00
parent 02547eeea6
commit f6a6f97e0b
22 changed files with 1078 additions and 780 deletions

View File

@@ -20,7 +20,6 @@ env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1
ENABLE_LEGACY_DELTA_FALLBACK: 'false'
jobs:
prepare:
@@ -318,56 +317,20 @@ jobs:
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh
- name: Install vpk
if: matrix.self_contained == true && matrix.arch == 'x64'
- name: Build Signed FileMap Update Package
if: matrix.self_contained == true
run: |
$ErrorActionPreference = "Stop"
dotnet tool uninstall --global vpk | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "vpk is not preinstalled, proceeding with fresh install."
}
dotnet tool install --global vpk --allow-roll-forward
"$env:USERPROFILE\\.dotnet\\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
$env:PATH = "$env:USERPROFILE\\.dotnet\\tools;$env:PATH"
vpk -h
shell: pwsh
- name: Prepare Previous Velopack Full Package
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$outputDir = "velopack-output"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
try {
$headers = @{ "User-Agent" = "LanMountainDesktop-CI"; "Authorization" = "token ${{ secrets.GITHUB_TOKEN }}" }
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/${{ github.repository }}/releases?per_page=10" -Headers $headers
$previousRelease = $releases | Where-Object { -not $_.prerelease -and -not $_.draft } | Select-Object -First 1
if ($previousRelease) {
$previousFull = $previousRelease.assets |
Where-Object { $_.name -like "*-full.nupkg" } |
Select-Object -First 1
if ($previousFull) {
$dest = Join-Path $outputDir $previousFull.name
Invoke-WebRequest -Uri $previousFull.browser_download_url -OutFile $dest -Headers $headers
Write-Host "Downloaded previous package for Velopack delta generation."
} else {
Write-Host "No previous full package found. Velopack will generate full package only."
}
}
} catch {
Write-Host "Could not fetch previous release package: $_"
}
shell: pwsh
- name: Build Velopack Packages
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$platform = "windows-$arch"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "velopack-output"
$outputDir = Join-Path "delta-output" $platform
$generateScript = "scripts/Generate-DeltaPackage.ps1"
$signScript = "scripts/Sign-FileMap.ps1"
if (-not (Test-Path $currentAppPath)) {
Write-Error "Expected app directory not found: $currentAppPath"
@@ -375,54 +338,65 @@ jobs:
}
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
vpk pack `
--packId LanMountainDesktop `
--packVersion $version `
--packDir $currentAppPath `
--mainExe LanMountainDesktop.exe `
--outputDir $outputDir `
--channel win `
--noPortable `
--skipVeloAppCheck
if ($LASTEXITCODE -ne 0) {
Write-Error "Velopack packaging failed."
exit 1
}
Get-ChildItem -Path $outputDir -File | Select-Object Name,Length
shell: pwsh
- name: Legacy Delta Fallback (disabled by default)
if: matrix.self_contained == true && matrix.arch == 'x64' && env.ENABLE_LEGACY_DELTA_FALLBACK == 'true'
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output"
$scriptPath = "scripts/Generate-DeltaPackage.ps1"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
& $scriptPath `
& $generateScript `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
$privateKeyPem = @'
${{ secrets.PDC_SIGNING_KEY }}
'@.Trim()
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
$privateKeyPem = @'
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
'@.Trim()
}
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
exit 1
}
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
$privateKeyPath = Join-Path $tempDir "private-key.pem"
$publicKeyPath = Join-Path $tempDir "public-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.ImportFromPem($privateKeyPem)
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
$repoPublicKey = (Get-Content -Path $repoPublicKeyPath -Raw).Trim()
if ($repoPublicKey -ne $derivedPublicKey.Trim()) {
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
exit 1
}
& $signScript `
-FilesJsonPath (Join-Path $outputDir "files.json") `
-PrivateKeyPath $privateKeyPath `
-OutputPath (Join-Path $outputDir "files.json.sig")
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
shell: pwsh
- name: Upload Velopack Package
if: matrix.self_contained == true && matrix.arch == 'x64'
- name: Upload Signed FileMap Update Package
if: matrix.self_contained == true
uses: actions/upload-artifact@v4
with:
name: release-velopack-windows-x64
name: release-update-windows-${{ matrix.arch }}
path: |
velopack-output/*.nupkg
velopack-output/releases.win.json
velopack-output/assets.win.json
velopack-output/RELEASES
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json
delta-output/windows-${{ matrix.arch }}/files-windows-${{ matrix.arch }}.json.sig
delta-output/windows-${{ matrix.arch }}/update-windows-${{ matrix.arch }}.zip
if-no-files-found: error
retention-days: 90
- name: Upload Installer
@@ -630,6 +604,86 @@ jobs:
exit 1
fi
- name: Build Signed FileMap Update Package
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ needs.prepare.outputs.version }}"
$platform = "linux-x64"
$publishDir = "publish/linux-x64"
$appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir
$outputDir = Join-Path "delta-output" $platform
$generateScript = "scripts/Generate-DeltaPackage.ps1"
$signScript = "scripts/Sign-FileMap.ps1"
if (-not (Test-Path $currentAppPath)) {
Write-Error "Expected app directory not found: $currentAppPath"
exit 1
}
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
& $generateScript `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
$privateKeyPem = @'
${{ secrets.PDC_SIGNING_KEY }}
'@.Trim()
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
$privateKeyPem = @'
${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
'@.Trim()
}
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Error "Missing required secret: PDC_SIGNING_KEY or UPDATE_PRIVATE_KEY_PEM"
exit 1
}
$privateKeyPem = $privateKeyPem -replace '\\n', "`n"
$tempDir = Join-Path $env:RUNNER_TEMP "update-signing"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
$privateKeyPath = Join-Path $tempDir "private-key.pem"
$publicKeyPath = Join-Path $tempDir "public-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -NoNewline
$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.ImportFromPem($privateKeyPem)
$derivedPublicKey = $rsa.ExportRSAPublicKeyPem()
Set-Content -Path $publicKeyPath -Value $derivedPublicKey -NoNewline
$repoPublicKeyPath = "LanMountainDesktop.Launcher/Assets/public-key.pem"
$repoPublicKey = (Get-Content -Path $repoPublicKeyPath -Raw).Trim()
if ($repoPublicKey -ne $derivedPublicKey.Trim()) {
Write-Error "Configured signing private key does not match $repoPublicKeyPath. Keep keypair consistent before publishing."
exit 1
}
& $signScript `
-FilesJsonPath (Join-Path $outputDir "files.json") `
-PrivateKeyPath $privateKeyPath `
-OutputPath (Join-Path $outputDir "files.json.sig")
Copy-Item (Join-Path $outputDir "files.json") (Join-Path $outputDir "files-$platform.json") -Force
Copy-Item (Join-Path $outputDir "files.json.sig") (Join-Path $outputDir "files-$platform.json.sig") -Force
Copy-Item (Join-Path $outputDir "update.zip") (Join-Path $outputDir "update-$platform.zip") -Force
- name: Upload Signed FileMap Update Package
uses: actions/upload-artifact@v4
with:
name: release-update-linux-x64
path: |
delta-output/linux-x64/files-linux-x64.json
delta-output/linux-x64/files-linux-x64.json.sig
delta-output/linux-x64/update-linux-x64.zip
if-no-files-found: error
retention-days: 90
- name: Upload
uses: actions/upload-artifact@v4
with:
@@ -832,8 +886,8 @@ jobs:
mkdir -p release-files
# Copy installers and packages
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
# Copy Velopack release feed and update packages
find artifacts -type f \( -name "releases.win.json" -o -name "assets.win.json" -o -name "RELEASES" -o -name "*.nupkg" \) -exec cp -v {} release-files/ \;
# Copy signed file-map incremental update assets
find artifacts -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-files/ \;
echo ""
echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files"
@@ -846,6 +900,38 @@ jobs:
exit 1
fi
- name: Upload Incremental Assets to S3 (optional)
if: ${{ vars.S3_ENDPOINT != '' && vars.S3_BUCKET != '' && secrets.S3_ACCESS_KEY != '' && secrets.S3_SECRET_KEY != '' }}
env:
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_REGION: ${{ vars.S3_REGION != '' && vars.S3_REGION || 'cn-nb1' }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_OBJECT_PREFIX: lanmountain/distribution-v1
run: |
set -euo pipefail
python3 -m pip install --upgrade awscli
mkdir -p release-update-assets
find release-files -type f \( -name "files-*.json" -o -name "files-*.json.sig" -o -name "update-*.zip" \) -exec cp -v {} release-update-assets/ \;
asset_count=$(find release-update-assets -type f | wc -l)
if [ "$asset_count" -eq 0 ]; then
echo "Error: no incremental update assets found for S3 upload."
exit 1
fi
export AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY"
export AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY"
export AWS_DEFAULT_REGION="$S3_REGION"
version_prefix="${S3_OBJECT_PREFIX}/${{ needs.prepare.outputs.version }}/"
latest_prefix="${S3_OBJECT_PREFIX}/latest/"
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${version_prefix}" --only-show-errors
aws --endpoint-url "$S3_ENDPOINT" s3 sync release-update-assets "s3://${S3_BUCKET}/${latest_prefix}" --delete --only-show-errors
- name: Create Release
uses: ncipollo/release-action@v1
with:
@@ -867,12 +953,12 @@ jobs:
Installation: Double-click the .exe file and follow the wizard.
### Incremental Update (Windows x64)
- **releases.win.json** - Velopack release feed consumed by the launcher update flow
- **LanMountainDesktop-<version>-full.nupkg** - full package
- **LanMountainDesktop-<version>-delta.nupkg** - delta package (when available)
### Incremental Update Assets
- **files-windows-x64.json / files-windows-x64.json.sig / update-windows-x64.zip**
- **files-windows-x86.json / files-windows-x86.json.sig / update-windows-x86.zip**
- **files-linux-x64.json / files-linux-x64.json.sig / update-linux-x64.zip**
Existing users: The app will automatically detect and apply the incremental update on next launch.
Existing users: Launcher will detect platform-matching signed assets and apply update on next startup.
### Linux
- **LanMountainDesktop-${{ needs.prepare.outputs.version }}-linux-x64.deb** - Debian package (x64)

View File

@@ -0,0 +1,10 @@
# Checklist
- [x] `release.yml` produces signed FileMap incremental assets for Windows x64/x86 and Linux x64.
- [x] `release.yml` no longer depends on `vpk`/VeloPack packaging.
- [x] Launcher update engine applies only signed FileMap payload path.
- [x] Host update workflow no longer expects `releases.win.json`/`*.nupkg`.
- [x] Update source setting includes `pdc` and preserves GitHub fallback behavior.
- [ ] CI run attached proving all release matrix jobs pass.
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
- [ ] Rollback verification report attached.

View File

@@ -0,0 +1,30 @@
# PDC Incremental Update Migration
## Goal
Replace VeloPack-based incremental packaging with a unified signed FileMap pipeline and prepare for PDC/S3 distribution compatibility, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
## Stage 1 (Completed in this round)
- Release workflow outputs signed FileMap incremental assets as the primary path:
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
- Launcher and host update runtime remove VeloPack branches and return to signed FileMap apply path.
- Host update asset discovery supports platform-scoped names with fallback to legacy generic names.
- Optional S3 sync publishes incremental assets in parallel with GitHub Release assets.
## Stage 2 (In Progress)
- Introduce PDC-compatible update source (`pdc`) with fallback to GitHub.
- Add PDC metadata/latest/distribution API consumption abstraction.
- Keep Launcher install/apply/rollback state machine unchanged.
- Prepare `phainon.yml`-compatible release metadata for future PDCC integration.
## Acceptance
- `release.yml` no longer contains VeloPack packaging steps.
- Windows x64/x86 and Linux x64 release jobs all upload signed FileMap incremental assets.
- Host auto-update can detect and download platform-matching signed FileMap assets.
- Launcher `update apply` succeeds with signed FileMap payload and rollback behavior remains unchanged.
- Optional S3 upload step works when S3 secrets/vars are configured.

View File

@@ -0,0 +1,12 @@
# Tasks
- [x] Remove VeloPack packaging from release workflow.
- [x] Promote signed FileMap generation to release primary path.
- [x] Output platform-scoped incremental assets for Windows x64/x86 and Linux x64.
- [x] Remove launcher/runtime VeloPack branches.
- [x] Update host asset discovery to platform-scoped signed FileMap naming.
- [x] Add optional S3 sync for incremental assets.
- [x] Extend update source values with `pdc`.
- [x] Add PDC check fallback service skeleton in settings domain.
- [ ] Add full PDC FileMap object-hash download/deploy path.
- [ ] Add PDCC publish integration and `phainon.yml` CI publishing flow.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -20,7 +20,4 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
[JsonSerializable(typeof(VelopackReleaseFeed))]
[JsonSerializable(typeof(VelopackReleaseAsset))]
[JsonSerializable(typeof(List<VelopackReleaseAsset>))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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."),

View File

@@ -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() ?? [];
}

View File

@@ -14,7 +14,6 @@ internal sealed class UpdateEngineService
private const string SignedFileMapName = "files.json";
private const string SignatureFileName = "files.json.sig";
private const string ArchiveFileName = "update.zip";
private const string VelopackReleasesFileName = "releases.win.json";
private const string PublicKeyFileName = "public-key.pem";
private readonly DeploymentLocator _deploymentLocator;
@@ -34,16 +33,6 @@ internal sealed class UpdateEngineService
public LauncherResult CheckPendingUpdate()
{
var velopackFeedPath = Path.Combine(_incomingRoot, VelopackReleasesFileName);
if (File.Exists(velopackFeedPath))
{
var velopackResult = CheckVelopackPendingUpdate(velopackFeedPath);
if (velopackResult is not null)
{
return velopackResult;
}
}
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
@@ -82,47 +71,6 @@ internal sealed class UpdateEngineService
};
}
public async Task<LauncherResult> DownloadVelopackAsync(
string releasesJsonUrl,
IReadOnlyList<string> packageUrls,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(releasesJsonUrl))
{
return Failed("update.download", "invalid_argument", "Missing releases feed url.");
}
Directory.CreateDirectory(_incomingRoot);
using var client = new HttpClient
{
Timeout = TimeSpan.FromMinutes(2)
};
var releasesPath = Path.Combine(_incomingRoot, VelopackReleasesFileName);
await DownloadToFileAsync(client, releasesJsonUrl, releasesPath, cancellationToken).ConfigureAwait(false);
foreach (var url in packageUrls.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct(StringComparer.OrdinalIgnoreCase))
{
var fileName = Path.GetFileName(new Uri(url).AbsolutePath);
if (string.IsNullOrWhiteSpace(fileName))
{
continue;
}
var destination = Path.Combine(_incomingRoot, fileName);
await DownloadToFileAsync(client, url, destination, cancellationToken).ConfigureAwait(false);
}
return new LauncherResult
{
Success = true,
Stage = "update.download",
Code = "ok",
Message = "Velopack update payload downloaded."
};
}
public async Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken)
{
Directory.CreateDirectory(_incomingRoot);
@@ -167,12 +115,6 @@ internal sealed class UpdateEngineService
Directory.CreateDirectory(_incomingRoot);
Directory.CreateDirectory(_snapshotsRoot);
var velopackFeedPath = Path.Combine(_incomingRoot, VelopackReleasesFileName);
if (File.Exists(velopackFeedPath))
{
return await ApplyVelopackPendingUpdateAsync(velopackFeedPath).ConfigureAwait(false);
}
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
@@ -631,8 +573,7 @@ internal sealed class UpdateEngineService
{
Path.Combine(_incomingRoot, SignedFileMapName),
Path.Combine(_incomingRoot, SignatureFileName),
Path.Combine(_incomingRoot, ArchiveFileName),
Path.Combine(_incomingRoot, VelopackReleasesFileName)
Path.Combine(_incomingRoot, ArchiveFileName)
})
{
try
@@ -646,17 +587,6 @@ internal sealed class UpdateEngineService
{
}
}
try
{
foreach (var nupkgPath in Directory.EnumerateFiles(_incomingRoot, "*.nupkg", SearchOption.TopDirectoryOnly))
{
File.Delete(nupkgPath);
}
}
catch
{
}
}
private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath)
@@ -724,307 +654,6 @@ internal sealed class UpdateEngineService
return Convert.ToHexString(hash).ToLowerInvariant();
}
private LauncherResult? CheckVelopackPendingUpdate(string feedPath)
{
try
{
var feed = JsonSerializer.Deserialize(File.ReadAllText(feedPath), AppJsonContext.Default.VelopackReleaseFeed);
if (feed?.Assets is null || feed.Assets.Count == 0)
{
return Failed("update.check", "invalid_manifest", "releases.win.json is invalid.");
}
var currentVersion = ParseVersionSafe(_deploymentLocator.GetCurrentVersion());
var latest = feed.Assets
.Where(a => string.Equals(a.Type, "Full", StringComparison.OrdinalIgnoreCase))
.Select(a => new { Asset = a, Version = ParseVersionSafe(a.Version) })
.Where(x => x.Version > currentVersion)
.OrderByDescending(x => x.Version)
.FirstOrDefault();
if (latest is null)
{
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "noop",
Message = "No pending update for current version."
};
}
var packagePath = Path.Combine(_incomingRoot, latest.Asset.FileName);
if (!File.Exists(packagePath))
{
return Failed("update.check", "missing_payload", $"Missing Velopack package '{latest.Asset.FileName}'.");
}
return new LauncherResult
{
Success = true,
Stage = "update.check",
Code = "available",
Message = "Pending Velopack update is available.",
CurrentVersion = _deploymentLocator.GetCurrentVersion(),
TargetVersion = latest.Asset.Version
};
}
catch (Exception ex)
{
return Failed("update.check", "invalid_manifest", ex.Message);
}
}
private async Task<LauncherResult> ApplyVelopackPendingUpdateAsync(string feedPath)
{
VelopackReleaseFeed? feed;
try
{
var json = await File.ReadAllTextAsync(feedPath).ConfigureAwait(false);
feed = JsonSerializer.Deserialize(json, AppJsonContext.Default.VelopackReleaseFeed);
}
catch (Exception ex)
{
return Failed("update.apply", "invalid_manifest", $"Invalid releases feed: {ex.Message}");
}
if (feed?.Assets is null || feed.Assets.Count == 0)
{
return Failed("update.apply", "invalid_manifest", "releases.win.json has no assets.");
}
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(currentDeployment))
{
return Failed("update.apply", "no_current_deployment", "Current deployment not found.");
}
var currentVersionText = _deploymentLocator.GetCurrentVersion();
var currentVersion = ParseVersionSafe(currentVersionText);
var target = feed.Assets
.Where(a => string.Equals(a.Type, "Full", StringComparison.OrdinalIgnoreCase))
.Select(a => new { Asset = a, Version = ParseVersionSafe(a.Version) })
.Where(x => x.Version > currentVersion)
.OrderByDescending(x => x.Version)
.FirstOrDefault();
if (target is null)
{
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "noop",
Message = "No Velopack update payload found."
};
}
var packagePath = Path.Combine(_incomingRoot, target.Asset.FileName);
if (!File.Exists(packagePath))
{
return Failed("update.apply", "missing_payload", $"Missing Velopack package '{target.Asset.FileName}'.");
}
if (!VerifyVelopackPackageChecksum(packagePath, target.Asset))
{
return Failed("update.apply", "checksum_failed", "Velopack package checksum verification failed.");
}
var targetVersion = string.IsNullOrWhiteSpace(target.Asset.Version) ? currentVersionText : target.Asset.Version;
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
var partialMarker = Path.Combine(targetDeployment, ".partial");
var snapshot = new SnapshotMetadata
{
SnapshotId = Guid.NewGuid().ToString("N"),
SourceVersion = currentVersionText,
TargetVersion = targetVersion,
CreatedAt = DateTimeOffset.UtcNow,
SourceDirectory = currentDeployment,
TargetDirectory = targetDeployment,
Status = "pending"
};
var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
var extractRoot = Path.Combine(_incomingRoot, "extracted-velopack");
try
{
SaveSnapshot(snapshotPath, snapshot);
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
Directory.CreateDirectory(extractRoot);
ZipFile.ExtractToDirectory(packagePath, extractRoot, overwriteFiles: true);
var contentRoot = ResolveVelopackContentRoot(extractRoot);
if (contentRoot is null)
{
throw new InvalidOperationException("Unable to locate app payload in Velopack package.");
}
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(partialMarker, string.Empty);
CopyDirectory(contentRoot, targetDeployment);
var hostExecutable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
if (!File.Exists(Path.Combine(targetDeployment, hostExecutable)))
{
throw new InvalidOperationException($"Host executable '{hostExecutable}' not found after applying Velopack package.");
}
ActivateDeployment(currentDeployment, targetDeployment);
snapshot.Status = "applied";
SaveSnapshot(snapshotPath, snapshot);
CleanupIncomingArtifacts();
CleanupDestroyedDeployments();
return new LauncherResult
{
Success = true,
Stage = "update.apply",
Code = "ok",
Message = $"Updated to {targetVersion}.",
CurrentVersion = currentVersionText,
TargetVersion = targetVersion
};
}
catch (Exception ex)
{
TryRollbackOnFailure(snapshot);
snapshot.Status = "rolled_back";
SaveSnapshot(snapshotPath, snapshot);
return new LauncherResult
{
Success = false,
Stage = "update.apply",
Code = "apply_failed",
Message = "Failed to apply update. Rolled back to previous version.",
ErrorMessage = ex.Message,
CurrentVersion = currentVersionText,
RolledBackTo = currentVersionText
};
}
finally
{
try
{
if (Directory.Exists(extractRoot))
{
Directory.Delete(extractRoot, true);
}
}
catch
{
}
}
}
private static Version ParseVersionSafe(string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
return new Version(0, 0, 0);
}
var normalized = version.Trim();
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
return Version.TryParse(normalized, out var parsed) ? parsed : new Version(0, 0, 0);
}
private static bool VerifyVelopackPackageChecksum(string packagePath, VelopackReleaseAsset asset)
{
try
{
if (!string.IsNullOrWhiteSpace(asset.SHA256))
{
var actualSha256 = ComputeSha256Hex(packagePath);
return string.Equals(actualSha256, asset.SHA256, StringComparison.OrdinalIgnoreCase);
}
if (!string.IsNullOrWhiteSpace(asset.SHA1))
{
using var stream = File.OpenRead(packagePath);
var sha1 = SHA1.HashData(stream);
var actualSha1 = Convert.ToHexString(sha1);
return string.Equals(actualSha1, asset.SHA1, StringComparison.OrdinalIgnoreCase);
}
return true;
}
catch
{
return false;
}
}
private static string? ResolveVelopackContentRoot(string extractRoot)
{
var hostExecutable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var hostPath = Directory
.EnumerateFiles(extractRoot, hostExecutable, SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(hostPath))
{
return Path.GetDirectoryName(hostPath);
}
// common nupkg layout fallback
var libRoot = Path.Combine(extractRoot, "lib");
if (Directory.Exists(libRoot))
{
var best = Directory.GetDirectories(libRoot, "*", SearchOption.TopDirectoryOnly)
.OrderByDescending(d => Directory.EnumerateFiles(d, "*", SearchOption.AllDirectories).Count())
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(best))
{
return best;
}
}
var candidate = Directory.GetDirectories(extractRoot, "*", SearchOption.TopDirectoryOnly)
.Where(d => !string.Equals(Path.GetFileName(d), "_rels", StringComparison.OrdinalIgnoreCase))
.Where(d => !string.Equals(Path.GetFileName(d), "package", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(d => Directory.EnumerateFiles(d, "*", SearchOption.AllDirectories).Count())
.FirstOrDefault();
return candidate;
}
private static void CopyDirectory(string sourceDir, string targetDir)
{
foreach (var dirPath in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(sourceDir, dirPath);
Directory.CreateDirectory(Path.Combine(targetDir, relative));
}
foreach (var sourceFile in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(sourceDir, sourceFile);
var destFile = Path.Combine(targetDir, relative);
var destDir = Path.GetDirectoryName(destFile);
if (!string.IsNullOrWhiteSpace(destDir))
{
Directory.CreateDirectory(destDir);
}
File.Copy(sourceFile, destFile, overwrite: true);
}
}
private static async Task DownloadToFileAsync(HttpClient client, string url, string destination, CancellationToken cancellationToken)
{
await using var stream = await client.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
await using var output = File.Create(destination);
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
}
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
{
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));

View File

@@ -85,7 +85,7 @@ public sealed class AppSettingsSnapshot
public string UpdateMode { get; set; } = "download_then_confirm";
public string UpdateDownloadSource { get; set; } = "github";
public string UpdateDownloadSource { get; set; } = "pdc";
public int UpdateDownloadThreads { get; set; } = 4;

View File

@@ -0,0 +1,464 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
/// <summary>
/// Best-effort PDC client that maps PDC responses to the existing update result model.
/// This keeps launcher update contracts stable while allowing a gradual migration.
/// </summary>
public sealed class PdcReleaseUpdateService : IDisposable
{
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public PdcReleaseUpdateService(HttpClient? httpClient = null)
{
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
var endpoint = ResolveEndpoint();
if (string.IsNullOrWhiteSpace(endpoint))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC endpoint is not configured.",
ForceMode: isForce);
}
try
{
var metadataUrl = BuildUri(endpoint, "api/v1/public/distributions/metadata");
var metadata = await GetContentNodeAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
var channelId = ResolveChannelId(metadata, includePrerelease);
if (string.IsNullOrWhiteSpace(channelId))
{
channelId = includePrerelease ? "preview" : "stable";
}
var latestUrl = BuildUri(
endpoint,
$"api/v1/public/distributions/latest/{Uri.EscapeDataString(channelId)}?appVersion={Uri.EscapeDataString(normalizedCurrentVersionText)}");
var latestNode = await GetContentNodeAsync(latestUrl, cancellationToken).ConfigureAwait(false);
var latestVersionText = ReadString(latestNode, "version") ?? "-";
if (!TryParseVersion(latestVersionText, out var latestVersion) || latestVersion is null)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution version is invalid.",
ForceMode: isForce);
}
var distributionId = ReadString(latestNode, "distributionId");
if (string.IsNullOrWhiteSpace(distributionId))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC latest distribution id is missing.",
ForceMode: isForce);
}
var hasUpdate = latestVersion > normalizedCurrentVersion;
if (!isForce && !hasUpdate)
{
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: null,
ForceMode: false);
}
var subChannel = ResolveSubChannel();
var distributionUrl = BuildUri(
endpoint,
$"api/v1/public/distributions/{Uri.EscapeDataString(distributionId)}/{Uri.EscapeDataString(subChannel)}");
var distributionNode = await GetContentNodeAsync(distributionUrl, cancellationToken).ConfigureAwait(false);
var assets = ResolveAssets(distributionNode);
if (assets.Count == 0)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: null,
PreferredAsset: null,
ErrorMessage: "PDC distribution response does not expose downloadable update assets.",
ForceMode: isForce);
}
var release = new GitHubReleaseInfo(
TagName: $"v{latestVersionText}",
Name: $"PDC Distribution {latestVersionText}",
IsPrerelease: includePrerelease,
IsDraft: false,
PublishedAt: DateTimeOffset.UtcNow,
Assets: assets);
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: true,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: null,
ErrorMessage: null,
ForceMode: isForce);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: $"PDC request failed: {ex.Message}",
ForceMode: isForce);
}
}
private async Task<JsonElement> GetContentNodeAsync(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
var token = ResolveToken();
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {Truncate(body, 180)}");
}
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("content", out var content))
{
return content.Clone();
}
return root.Clone();
}
private static IReadOnlyList<GitHubReleaseAsset> ResolveAssets(JsonElement distributionNode)
{
var assets = new List<GitHubReleaseAsset>();
if (distributionNode.ValueKind != JsonValueKind.Object)
{
return assets;
}
if (distributionNode.TryGetProperty("assets", out var assetsNode) &&
assetsNode.ValueKind == JsonValueKind.Array)
{
foreach (var assetNode in assetsNode.EnumerateArray())
{
if (assetNode.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = ReadString(assetNode, "name");
var url = ReadString(assetNode, "url") ??
ReadString(assetNode, "downloadUrl") ??
ReadString(assetNode, "browserDownloadUrl");
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url))
{
continue;
}
var size = ReadInt64(assetNode, "size") ?? 0L;
var sha256 = ReadString(assetNode, "sha256");
assets.Add(new GitHubReleaseAsset(name, url, size, sha256));
}
}
if (assets.Count > 0)
{
return assets;
}
// Field-level fallback for service-side URL projection.
var manifestUrl = ReadString(distributionNode, "manifestUrl")
?? ReadString(distributionNode, "fileMapUrl");
var signatureUrl = ReadString(distributionNode, "signatureUrl")
?? ReadString(distributionNode, "fileMapSignatureUrl");
var archiveUrl = ReadString(distributionNode, "archiveUrl")
?? ReadString(distributionNode, "updateArchiveUrl")
?? ReadString(distributionNode, "payloadUrl");
if (!string.IsNullOrWhiteSpace(manifestUrl))
{
assets.Add(new GitHubReleaseAsset("files.json", manifestUrl, 0, null));
}
if (!string.IsNullOrWhiteSpace(signatureUrl))
{
assets.Add(new GitHubReleaseAsset("files.json.sig", signatureUrl, 0, null));
}
if (!string.IsNullOrWhiteSpace(archiveUrl))
{
assets.Add(new GitHubReleaseAsset("update.zip", archiveUrl, 0, null));
}
return assets;
}
private static string ResolveChannelId(JsonElement metadataNode, bool includePrerelease)
{
if (metadataNode.ValueKind != JsonValueKind.Object ||
!metadataNode.TryGetProperty("channels", out var channelsNode))
{
return includePrerelease ? "preview" : "stable";
}
var defaultChannelId = ReadString(metadataNode, "defaultChannelId") ?? string.Empty;
if (channelsNode.ValueKind != JsonValueKind.Object)
{
return defaultChannelId;
}
string? matchedPreview = null;
string? matchedStable = null;
foreach (var channel in channelsNode.EnumerateObject())
{
var name = ReadString(channel.Value, "name") ?? channel.Name;
if (string.IsNullOrWhiteSpace(matchedPreview) &&
(name.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
name.Contains("beta", StringComparison.OrdinalIgnoreCase) ||
name.Contains("dev", StringComparison.OrdinalIgnoreCase)))
{
matchedPreview = channel.Name;
}
if (string.IsNullOrWhiteSpace(matchedStable) &&
(name.Contains("stable", StringComparison.OrdinalIgnoreCase) ||
name.Contains("release", StringComparison.OrdinalIgnoreCase)))
{
matchedStable = channel.Name;
}
}
if (includePrerelease)
{
return matchedPreview ?? defaultChannelId ?? "preview";
}
return matchedStable ?? defaultChannelId ?? "stable";
}
private static string ResolveSubChannel()
{
var configured = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_SUBCHANNEL")
?? Environment.GetEnvironmentVariable("PDC_SUBCHANNEL");
if (!string.IsNullOrWhiteSpace(configured))
{
return configured.Trim();
}
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X86 => "x86",
Architecture.Arm => "arm",
Architecture.Arm64 => "arm64",
_ => "x64"
};
return $"{os}_{arch}_release_folderClassic";
}
private static string? ResolveEndpoint()
{
var endpoint = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_ENDPOINT")
?? Environment.GetEnvironmentVariable("PDC_ENDPOINT");
return string.IsNullOrWhiteSpace(endpoint) ? null : endpoint.Trim().TrimEnd('/');
}
private static string? ResolveToken()
{
var token = Environment.GetEnvironmentVariable("LANMOUNTAIN_PDC_TOKEN")
?? Environment.GetEnvironmentVariable("PDC_TOKEN");
return string.IsNullOrWhiteSpace(token) ? null : token.Trim();
}
private static string BuildUri(string endpoint, string relativePath)
{
return $"{endpoint.TrimEnd('/')}/{relativePath.TrimStart('/')}";
}
private static string? ReadString(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String
? value.GetString()
: value.ToString();
}
private static long? ReadInt64(JsonElement node, string propertyName)
{
if (node.ValueKind != JsonValueKind.Object || !node.TryGetProperty(propertyName, out var value))
{
return null;
}
if (value.TryGetInt64(out var number))
{
return number;
}
var text = value.ToString();
return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim().TrimStart('v', 'V');
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = NormalizeVersion(parsed);
return true;
}
private static Version NormalizeVersion(Version version)
{
var major = Math.Max(0, version.Major);
var minor = Math.Max(0, version.Minor);
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
return revision > 0
? new Version(major, minor, build, revision)
: new Version(major, minor, build);
}
private static string FormatVersionText(Version version)
{
return version.Revision > 0
? version.ToString(4)
: version.ToString(3);
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength];
}
}

View File

@@ -751,7 +751,8 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
{
private readonly ISettingsService _settingsService;
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly PdcReleaseUpdateService _pdcReleaseUpdateService = new();
public UpdateSettingsService(ISettingsService settingsService)
{
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
@@ -838,7 +839,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
@@ -849,7 +850,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.DownloadAssetAsync(
return _githubReleaseUpdateService.DownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
@@ -866,7 +867,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.RedownloadAssetAsync(
return _githubReleaseUpdateService.RedownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
@@ -877,7 +878,36 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public void Dispose()
{
_releaseUpdateService.Dispose();
_githubReleaseUpdateService.Dispose();
_pdcReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
{
var pdcResult = isForce
? await _pdcReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _pdcReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (pdcResult.Success)
{
return pdcResult;
}
AppLogger.Warn(
"UpdateSettings",
$"PDC update check failed and will fallback to GitHub. Error: {pdcResult.ErrorMessage}");
}
return isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
}

View File

@@ -11,6 +11,7 @@ public static class UpdateSettingsValues
public const string ModeDownloadThenConfirm = "download_then_confirm";
public const string ModeSilentOnExit = "silent_on_exit";
public const string DownloadSourcePdc = "pdc";
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
@@ -51,9 +52,23 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
? DownloadSourceGhProxy
: DownloadSourceGitHub;
if (string.Equals(value, DownloadSourcePdc, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePdc;
}
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceGhProxy;
}
if (string.Equals(value, DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceGitHub;
}
// Default to PDC. Runtime will fallback to GitHub if PDC is unavailable.
return DownloadSourcePdc;
}
public static int NormalizeDownloadThreads(int value)

View File

@@ -5,6 +5,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.PluginSdk;
@@ -52,7 +53,9 @@ public sealed class UpdateWorkflowService
private const string LauncherDirectoryName = ".launcher";
private const string UpdateDirectoryName = "update";
private const string IncomingDirectoryName = "incoming";
private const string VelopackReleasesFileName = "releases.win.json";
private const string SignedFileMapName = "files.json";
private const string SignedFileMapSignatureName = "files.json.sig";
private const string UpdateArchiveName = "update.zip";
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{
@@ -79,7 +82,7 @@ public sealed class UpdateWorkflowService
}
/// <summary>
/// Checks whether a GitHub Release contains Velopack assets needed for incremental updates.
/// Checks whether a GitHub Release contains signed file-map assets needed for incremental updates.
/// </summary>
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
{
@@ -88,13 +91,11 @@ public sealed class UpdateWorkflowService
return false;
}
var hasFeed = release.Assets.Any(a => string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase));
var hasFull = release.Assets.Any(a => a.Name.EndsWith("-full.nupkg", StringComparison.OrdinalIgnoreCase));
return hasFeed && hasFull;
return TryResolveDeltaAssets(release.Assets, out _, out _, out _);
}
/// <summary>
/// Downloads Velopack release feed and package files to the Launcher's incoming directory.
/// Downloads signed file-map assets to the Launcher's incoming directory.
/// </summary>
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
UpdateCheckResult checkResult,
@@ -108,11 +109,9 @@ public sealed class UpdateWorkflowService
return new UpdateDownloadResult(false, null, "No update available for delta download.");
}
var releasesFeedAsset = checkResult.Release.Assets.FirstOrDefault(a =>
string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase));
if (releasesFeedAsset is null)
if (!TryResolveDeltaAssets(checkResult.Release.Assets, out var manifestAsset, out var signatureAsset, out var archiveAsset))
{
return new UpdateDownloadResult(false, null, "Release does not contain releases.win.json.");
return new UpdateDownloadResult(false, null, "Release does not contain compatible signed file-map assets.");
}
var incomingDir = GetLauncherIncomingDirectory();
@@ -130,29 +129,19 @@ public sealed class UpdateWorkflowService
var downloadSource = state.UpdateDownloadSource;
var downloadThreads = state.UpdateDownloadThreads;
var latestVersionText = checkResult.LatestVersionText.Trim();
var targetPackages = checkResult.Release.Assets
.Where(a => a.Name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))
.Where(a => a.Name.Contains(latestVersionText, StringComparison.OrdinalIgnoreCase))
.Where(a =>
a.Name.EndsWith("-full.nupkg", StringComparison.OrdinalIgnoreCase) ||
a.Name.EndsWith("-delta.nupkg", StringComparison.OrdinalIgnoreCase))
.ToList();
if (targetPackages.Count == 0)
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
{
return new UpdateDownloadResult(false, null, "No Velopack nupkg asset found for the target version.");
}
var requiredAssets = new List<GitHubReleaseAsset> { releasesFeedAsset };
requiredAssets.AddRange(targetPackages);
(manifestAsset, SignedFileMapName),
(signatureAsset, SignedFileMapSignatureName),
(archiveAsset, UpdateArchiveName)
};
var totalAssets = requiredAssets.Count;
var completedAssets = 0;
foreach (var asset in requiredAssets)
foreach (var (asset, destinationFileName) in requiredAssets)
{
var destinationPath = Path.Combine(incomingDir, asset.Name);
var destinationPath = Path.Combine(incomingDir, destinationFileName);
// Skip if already downloaded and file exists
if (File.Exists(destinationPath))
@@ -160,7 +149,7 @@ public sealed class UpdateWorkflowService
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
{
AppLogger.Info("UpdateWorkflow", $"Velopack asset {asset.Name} already downloaded with matching hash, skipping.");
AppLogger.Info("UpdateWorkflow", $"Update asset {asset.Name} already downloaded with matching hash, skipping.");
completedAssets++;
progress?.Report((double)completedAssets / totalAssets);
continue;
@@ -184,21 +173,21 @@ public sealed class UpdateWorkflowService
if (!result.Success)
{
// Clean up partially downloaded files
foreach (var file in requiredAssets.Select(a => a.Name))
foreach (var file in requiredAssets.Select(a => a.DestinationFileName))
{
try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
}
return new UpdateDownloadResult(false, null, $"Failed to download Velopack asset {asset.Name}: {result.ErrorMessage}");
return new UpdateDownloadResult(false, null, $"Failed to download update asset {asset.Name}: {result.ErrorMessage}");
}
completedAssets++;
progress?.Report((double)completedAssets / totalAssets);
}
// Save state indicating a Velopack update is pending.
// Save state indicating a signed file-map update is pending.
SaveState(state with
{
PendingUpdateInstallerPath = Path.Combine(incomingDir, VelopackReleasesFileName),
PendingUpdateInstallerPath = Path.Combine(incomingDir, SignedFileMapName),
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null
@@ -207,9 +196,9 @@ public sealed class UpdateWorkflowService
PendingUpdateSha256 = null
});
AppLogger.Info("UpdateWorkflow", $"Velopack update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
AppLogger.Info("UpdateWorkflow", $"Signed file-map update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
return new UpdateDownloadResult(true, Path.Combine(incomingDir, VelopackReleasesFileName), null);
return new UpdateDownloadResult(true, Path.Combine(incomingDir, SignedFileMapName), null);
}
/// <summary>
@@ -224,11 +213,71 @@ public sealed class UpdateWorkflowService
return false;
}
// Velopack updates are identified by the releases feed path.
return pendingPath.EndsWith(VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase)
// Incoming payload updates are identified by files.json or incoming directory path.
return pendingPath.EndsWith(SignedFileMapName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
}
private static bool TryResolveDeltaAssets(
IReadOnlyList<GitHubReleaseAsset> assets,
out GitHubReleaseAsset manifestAsset,
out GitHubReleaseAsset signatureAsset,
out GitHubReleaseAsset archiveAsset)
{
manifestAsset = default!;
signatureAsset = default!;
archiveAsset = default!;
if (assets is null || assets.Count == 0)
{
return false;
}
var platformSuffix = GetPlatformAssetSuffix();
var platformManifest = $"files-{platformSuffix}.json";
var platformSignature = $"files-{platformSuffix}.json.sig";
var platformArchive = $"update-{platformSuffix}.zip";
var manifestCandidate = FindAsset(assets, platformManifest) ?? FindAsset(assets, SignedFileMapName);
var signatureCandidate = FindAsset(assets, platformSignature) ?? FindAsset(assets, SignedFileMapSignatureName);
var archiveCandidate = FindAsset(assets, platformArchive) ?? FindAsset(assets, UpdateArchiveName);
if (manifestCandidate is null || signatureCandidate is null || archiveCandidate is null)
{
return false;
}
manifestAsset = manifestCandidate;
signatureAsset = signatureCandidate;
archiveAsset = archiveCandidate;
return true;
}
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string name)
{
return assets.FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
}
private static string GetPlatformAssetSuffix()
{
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X86 => "x86",
Architecture.Arm => "arm",
Architecture.Arm64 => "arm64",
_ => "x64"
};
return $"{os}-{arch}";
}
public UpdatePendingInfo? GetPendingUpdate()
{
var state = _settingsFacade.Update.Get();

View File

@@ -1496,7 +1496,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
[ObservableProperty]
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
[ObservableProperty]
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
@@ -1630,6 +1630,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _previewChannelText = string.Empty;
[ObservableProperty]
private string _pdcSourceText = string.Empty;
[ObservableProperty]
private string _gitHubSourceText = string.Empty;
@@ -1666,6 +1669,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public bool IsPreviewChannelSelected =>
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
public bool IsPdcSourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
public bool IsGitHubSourceSelected =>
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
@@ -1858,6 +1864,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
}
[RelayCommand]
private void SelectPdcSource()
{
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
}
[RelayCommand]
private void SelectGitHubSource()
{
@@ -1929,8 +1941,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = isForce
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
: L("settings.update.status_checking", "Checking GitHub releases...");
? L("settings.update.status_force_checking", "Force checking update source...")
: L("settings.update.status_checking", "Checking update source...");
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
_lastCheckResult = result.Success ? result : null;
@@ -2100,7 +2112,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
@@ -2112,6 +2124,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
StableChannelText = L("settings.update.channel_stable", "Stable");
PreviewChannelText = L("settings.update.channel_preview", "Preview");
PdcSourceText = L("settings.update.source_pdc", "PDC");
GitHubSourceText = L("settings.update.source_github", "GitHub");
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
ManualModeText = L("settings.update.mode_manual", "Manual Update");
@@ -2309,6 +2322,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
{
UpdateSettingsValues.DownloadSourcePdc => L(
"settings.update.source_pdc_desc",
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
UpdateSettingsValues.DownloadSourceGhProxy => L(
"settings.update.source_ghproxy_desc",
"Use the gh-proxy mirror when downloading GitHub release assets."),
@@ -2360,6 +2376,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
return
[
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
];

29
phainon.yml Normal file
View File

@@ -0,0 +1,29 @@
# Phainon Distribution Center (PDC) publish configuration
# This file is intentionally conservative: Launcher remains installer/rollback authority.
name: "LanMountainDesktop"
components:
app:
allowDiffUpdate: true
root: "app-$(version)/"
includes:
- "**"
launcher:
root: ""
includes:
- "**"
excludes:
- "app-*/**"
- ".launcher/update/incoming/**"
- "files.json"
- "files.json.sig"
- "update.zip"
variables:
number: 0
# Replace these roots in CI/CD or environment-specific templates when enabling PDCC publish.
fileRepoRoot: "https://example.invalid/lanmountain/distribution-v1/repo/"
archiveRoot: "https://example.invalid/lanmountain/distribution-v1/$(primaryVersion)/$(version)/"
bucketKeyRoot: "lanmountain/distribution-v1/repo/"
archiveBucketKeyRoot: "lanmountain/distribution-v1/$(primaryVersion)/$(version)/"

View File

@@ -1,105 +1,152 @@
# Generate-DeltaPackage.ps1
# 生成增量更新包 (delta.zip + files.json)
param(
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$PreviousVersion,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$CurrentVersion,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$PreviousDir,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$CurrentDir,
[Parameter(Mandatory=$true)]
[Parameter(Mandatory = $true)]
[string]$OutputDir
)
$ErrorActionPreference = "Stop"
Write-Host "=== 生成增量更新包 ===" -ForegroundColor Cyan
Write-Host "从版本: $PreviousVersion"
Write-Host "到版本: $CurrentVersion"
Write-Host "上一版本目录: $PreviousDir"
Write-Host "当前版本目录: $CurrentDir"
Write-Host "输出目录: $OutputDir"
Write-Host ""
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
# 确保输出目录存在
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
function Get-NormalizedRelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$RootDir,
# 计算文件 SHA256
function Get-FileSha256 {
param([string]$Path)
$hash = Get-FileHash -Path $Path -Algorithm SHA256
return $hash.Hash.ToLower()
[Parameter(Mandatory = $true)]
[string]$FullPath
)
$root = [System.IO.Path]::GetFullPath($RootDir)
$path = [System.IO.Path]::GetFullPath($FullPath)
if (-not $root.EndsWith([System.IO.Path]::DirectorySeparatorChar.ToString()) -and
-not $root.EndsWith([System.IO.Path]::AltDirectorySeparatorChar.ToString())) {
$root += [System.IO.Path]::DirectorySeparatorChar
}
$rootUri = [System.Uri]$root
$pathUri = [System.Uri]$path
$relative = [System.Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString())
return $relative.Replace('\', '/')
}
function Get-FileSha256Hex {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
# 获取目录中所有文件的相对路径和哈希
function Get-FileManifest {
param([string]$RootDir)
param(
[Parameter(Mandatory = $true)]
[string]$RootDir
)
if (-not (Test-Path -LiteralPath $RootDir)) {
throw "Directory does not exist: $RootDir"
}
$resolvedRoot = (Resolve-Path -LiteralPath $RootDir).Path
$manifest = @{}
$files = Get-ChildItem -Path $RootDir -Recurse -File
$files = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File
foreach ($file in $files) {
$relativePath = $file.FullName.Substring($RootDir.Length).TrimStart('\', '/')
$relativePath = $relativePath.Replace('\', '/')
$manifest[$relativePath] = @{
$relativePath = Get-NormalizedRelativePath -RootDir $resolvedRoot -FullPath $file.FullName
$manifest[$relativePath] = [ordered]@{
Path = $relativePath
Sha256 = Get-FileSha256 -Path $file.FullName
Size = $file.Length
Sha256 = Get-FileSha256Hex -Path $file.FullName
Size = [long]$file.Length
}
}
return $manifest
}
Write-Host "扫描上一版本文件..." -ForegroundColor Yellow
Write-Host " 目录: $PreviousDir" -ForegroundColor Gray
if (-not (Test-Path $PreviousDir)) {
throw "Previous directory does not exist: $PreviousDir"
function New-DeltaArchive {
param(
[Parameter(Mandatory = $true)]
[string]$ZipPath,
[Parameter(Mandatory = $true)]
[string]$CurrentRoot,
[Parameter(Mandatory = $true)]
[object[]]$ChangedFiles
)
if (Test-Path -LiteralPath $ZipPath) {
Remove-Item -LiteralPath $ZipPath -Force
}
$zip = [System.IO.Compression.ZipFile]::Open($ZipPath, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($file in $ChangedFiles) {
$sourcePath = Join-Path $CurrentRoot $file.Path
if (-not (Test-Path -LiteralPath $sourcePath)) {
throw "Changed file was not found while building archive: $sourcePath"
}
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip,
$sourcePath,
$file.Path,
[System.IO.Compression.CompressionLevel]::Optimal
) | Out-Null
}
}
finally {
$zip.Dispose()
}
}
Write-Host "Generating incremental package..."
Write-Host "From: $PreviousVersion"
Write-Host "To: $CurrentVersion"
Write-Host "Prev: $PreviousDir"
Write-Host "Curr: $CurrentDir"
Write-Host "Out: $OutputDir"
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
$previousManifest = Get-FileManifest -RootDir $PreviousDir
Write-Host " 找到 $($previousManifest.Count) 个文件" -ForegroundColor Gray
Write-Host "扫描当前版本文件..." -ForegroundColor Yellow
Write-Host " 目录: $CurrentDir" -ForegroundColor Gray
if (-not (Test-Path $CurrentDir)) {
throw "Current directory does not exist: $CurrentDir"
}
$currentManifest = Get-FileManifest -RootDir $CurrentDir
Write-Host " 找到 $($currentManifest.Count) 个文件" -ForegroundColor Gray
# 分析文件变更
$changedFiles = @()
$reusedFiles = @()
$deletedFiles = @()
Write-Host "分析文件变更..." -ForegroundColor Yellow
# 检查新增和修改的文件
foreach ($path in $currentManifest.Keys) {
foreach ($path in ($currentManifest.Keys | Sort-Object)) {
$currentFile = $currentManifest[$path]
if ($previousManifest.ContainsKey($path)) {
$previousFile = $previousManifest[$path]
if ($currentFile.Sha256 -eq $previousFile.Sha256) {
# 文件未变更,可以复用
$reusedFiles += @{
$reusedFiles += [ordered]@{
Path = $path
Action = "reuse"
Sha256 = $currentFile.Sha256
Size = $currentFile.Size
}
} else {
# 文件已修改
$changedFiles += @{
}
else {
$changedFiles += [ordered]@{
Path = $path
Action = "replace"
Sha256 = $currentFile.Sha256
@@ -107,9 +154,9 @@ foreach ($path in $currentManifest.Keys) {
ArchivePath = $path
}
}
} else {
# 新增文件
$changedFiles += @{
}
else {
$changedFiles += [ordered]@{
Path = $path
Action = "add"
Sha256 = $currentFile.Sha256
@@ -119,104 +166,51 @@ foreach ($path in $currentManifest.Keys) {
}
}
# 检查删除的文件
foreach ($path in $previousManifest.Keys) {
foreach ($path in ($previousManifest.Keys | Sort-Object)) {
if (-not $currentManifest.ContainsKey($path)) {
$deletedFiles += @{
$deletedFiles += [ordered]@{
Path = $path
Action = "delete"
}
}
}
Write-Host "变更统计:" -ForegroundColor Green
Write-Host " 新增/修改: $($changedFiles.Count) 个文件"
Write-Host " 复用: $($reusedFiles.Count) 个文件"
Write-Host " 删除: $($deletedFiles.Count) 个文件"
Write-Host ""
Write-Host "Changed: $($changedFiles.Count)"
Write-Host "Reused: $($reusedFiles.Count)"
Write-Host "Deleted: $($deletedFiles.Count)"
# 显示前10个变更的文件用于调试
if ($changedFiles.Count -gt 0) {
Write-Host "变更的文件示例:" -ForegroundColor Cyan
$changedFiles | Select-Object -First 10 | ForEach-Object {
Write-Host " [$($_.Action)] $($_.Path)" -ForegroundColor Gray
}
if ($changedFiles.Count -gt 10) {
Write-Host " ... 还有 $($changedFiles.Count - 10) 个文件" -ForegroundColor Gray
}
Write-Host ""
}
# 创建临时目录用于打包
$tempDir = Join-Path $OutputDir "temp_delta"
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
# 复制变更的文件到临时目录
Write-Host "复制变更文件..." -ForegroundColor Yellow
foreach ($file in $changedFiles) {
$sourcePath = Join-Path $CurrentDir $file.Path
$destPath = Join-Path $tempDir $file.Path
$destDir = Split-Path -Parent $destPath
if (-not (Test-Path $destDir)) {
New-Item -ItemType Directory -Force -Path $destDir | Out-Null
}
Copy-Item -Path $sourcePath -Destination $destPath -Force
}
# 创建 update.zip (Launcher 期望的文件名)
$resolvedCurrentDir = (Resolve-Path -LiteralPath $CurrentDir).Path
$updateZipPath = Join-Path $OutputDir "update.zip"
Write-Host "创建增量包: $updateZipPath" -ForegroundColor Yellow
New-DeltaArchive -ZipPath $updateZipPath -CurrentRoot $resolvedCurrentDir -ChangedFiles $changedFiles
if (Test-Path $updateZipPath) {
Remove-Item -Path $updateZipPath -Force
}
$deltaZipPath = Join-Path $OutputDir ("delta-{0}-to-{1}.zip" -f $PreviousVersion, $CurrentVersion)
Copy-Item -LiteralPath $updateZipPath -Destination $deltaZipPath -Force
Compress-Archive -Path "$tempDir\*" -DestinationPath $updateZipPath -CompressionLevel Optimal
# 同时创建带版本号的副本(用于发布到 GitHub Release
$deltaZipPath = Join-Path $OutputDir "delta-$PreviousVersion-to-$CurrentVersion.zip"
Write-Host "创建带版本号的副本: $deltaZipPath" -ForegroundColor Yellow
if (Test-Path $deltaZipPath) {
Remove-Item -Path $deltaZipPath -Force
}
Copy-Item -Path $updateZipPath -Destination $deltaZipPath -Force
# 清理临时目录
Remove-Item -Path $tempDir -Recurse -Force
# 生成 files.json (Launcher 期望的文件名)
$filesJson = @{
$allEntries = @($changedFiles + $reusedFiles + $deletedFiles)
$filesJson = [ordered]@{
FromVersion = $PreviousVersion
ToVersion = $CurrentVersion
GeneratedAt = (Get-Date).ToUniversalTime().ToString("o")
Files = @($changedFiles + $reusedFiles + $deletedFiles)
GeneratedAt = [DateTimeOffset]::UtcNow.ToString("o")
Files = $allEntries
}
$jsonText = $filesJson | ConvertTo-Json -Depth 10
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$filesJsonPath = Join-Path $OutputDir "files.json"
Write-Host "生成文件清单: $filesJsonPath" -ForegroundColor Yellow
[System.IO.File]::WriteAllText($filesJsonPath, $jsonText, $utf8NoBom)
$filesJson | ConvertTo-Json -Depth 10 | Set-Content -Path $filesJsonPath -Encoding UTF8
$versionedFilesJsonPath = Join-Path $OutputDir ("files-{0}.json" -f $CurrentVersion)
Copy-Item -LiteralPath $filesJsonPath -Destination $versionedFilesJsonPath -Force
# 同时创建带版本号的副本(用于发布到 GitHub Release
$versionedFilesJsonPath = Join-Path $OutputDir "files-$CurrentVersion.json"
Write-Host "创建带版本号的副本: $versionedFilesJsonPath" -ForegroundColor Yellow
Copy-Item -Path $filesJsonPath -Destination $versionedFilesJsonPath -Force
# 计算增量包大小
$updateSize = (Get-Item $updateZipPath).Length
$updateSizeMB = [math]::Round($updateSize / 1MB, 2)
$updateSizeBytes = (Get-Item -LiteralPath $updateZipPath).Length
$updateSizeMb = [Math]::Round($updateSizeBytes / 1MB, 2)
Write-Host ""
Write-Host "=== 完成 ===" -ForegroundColor Green
Write-Host "增量包大小: $updateSizeMB MB"
Write-Host "输出文件 (Launcher 使用):"
Write-Host " - $updateZipPath"
Write-Host " - $filesJsonPath"
Write-Host "输出文件 (GitHub Release 发布):"
Write-Host " - $deltaZipPath"
Write-Host " - $versionedFilesJsonPath"
Write-Host "Done."
Write-Host "update.zip size: $updateSizeMb MB"
Write-Host "Generated:"
Write-Host " $updateZipPath"
Write-Host " $filesJsonPath"
Write-Host " $deltaZipPath"
Write-Host " $versionedFilesJsonPath"

View File

@@ -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"