changed.velopack,试试rust

This commit is contained in:
lincube
2026-04-19 12:36:14 +08:00
parent 4f9feafbbe
commit 8e21364eed
16 changed files with 615 additions and 309 deletions

View File

@@ -25,7 +25,7 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4

View File

@@ -20,6 +20,7 @@ env:
DOTNET_VERSION: '10.0.x' DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1 DOTNET_gcServer: 1
ENABLE_LEGACY_DELTA_FALLBACK: 'false'
jobs: jobs:
prepare: prepare:
@@ -109,7 +110,7 @@ jobs:
Write-Host "Publishing Launcher with AOT for Windows $arch..." Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT 单文件发布 # AOT publish
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj ` dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release ` -c Release `
-o ./$launcherPublishDir ` -o ./$launcherPublishDir `
@@ -127,7 +128,7 @@ jobs:
exit 1 exit 1
} }
# 显示发布结果 # 鏄剧ず鍙戝竷缁撴灉
Write-Host "Launcher published to: $launcherPublishDir" Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1 $exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) { if ($exeFile) {
@@ -135,7 +136,7 @@ jobs:
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)" Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
} }
# 清理不必要的文件AOT 单文件应该只有一个 exe # Warn if unexpected extra files are produced
$files = Get-ChildItem -Path $launcherPublishDir -File $files = Get-ChildItem -Path $launcherPublishDir -File
if ($files.Count -gt 1) { if ($files.Count -gt 1) {
Write-Host "Warning: Expected single file but found $($files.Count) files" Write-Host "Warning: Expected single file but found $($files.Count) files"
@@ -317,7 +318,42 @@ jobs:
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB" Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh shell: pwsh
- name: Create App Package - name: Install vpk
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
dotnet tool install --global vpk
echo "$env:USERPROFILE\\.dotnet\\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
vpk --version
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' if: matrix.self_contained == true && matrix.arch == 'x64'
run: | run: |
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
@@ -325,21 +361,33 @@ jobs:
$publishDir = "publish/windows-$arch" $publishDir = "publish/windows-$arch"
$appDir = "app-$version" $appDir = "app-$version"
$currentAppPath = Join-Path $publishDir $appDir $currentAppPath = Join-Path $publishDir $appDir
$outputDir = "delta-output" $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 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
# 创建 app-{version}-win-{arch}.zip 供后续版本作为旧版本对比 if ($LASTEXITCODE -ne 0) {
$appZipPath = Join-Path $outputDir "app-$version-win-$arch.zip" Write-Error "Velopack packaging failed."
Write-Host "Creating app-$version-win-$arch.zip..." exit 1
Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal }
$sizeMB = [Math]::Round((Get-Item $appZipPath).Length / 1MB, 2) Get-ChildItem -Path $outputDir -File | Select-Object Name,Length
Write-Host "Created app-$version-win-$arch.zip: $sizeMB MB"
shell: pwsh shell: pwsh
- name: Generate Delta Package - name: Legacy Delta Fallback (disabled by default)
if: matrix.self_contained == true && matrix.arch == 'x64' if: matrix.self_contained == true && matrix.arch == 'x64' && env.ENABLE_LEGACY_DELTA_FALLBACK == 'true'
run: | run: |
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
@@ -350,143 +398,26 @@ jobs:
$scriptPath = "scripts/Generate-DeltaPackage.ps1" $scriptPath = "scripts/Generate-DeltaPackage.ps1"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# --- Determine previous version and download its app package for diff ---
$previousVersion = $null
$previousAppPath = $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) {
$previousVersion = $previousRelease.tag_name.TrimStart('v','V')
Write-Host "Previous release version: $previousVersion"
# 下载旧版本的 app-{version}-win-{arch}.zip
$prevAppZip = $previousRelease.assets | Where-Object { $_.name -eq "app-$previousVersion-win-$arch.zip" } | Select-Object -First 1
if ($prevAppZip) {
Write-Host "Found app-$previousVersion-win-$arch.zip in previous release - downloading for diff..."
$prevAppZipDest = Join-Path $outputDir "prev-app.zip"
Invoke-WebRequest -Uri $prevAppZip.browser_download_url -OutFile $prevAppZipDest -Headers $headers
# 解压 app-{version}.zip
$previousAppPath = Join-Path $outputDir "prev-app"
New-Item -ItemType Directory -Path $previousAppPath -Force | Out-Null
Expand-Archive -Path $prevAppZipDest -DestinationPath $previousAppPath -Force
Remove-Item -Path $prevAppZipDest -Force -ErrorAction SilentlyContinue
if ($previousAppPath -and (Test-Path $previousAppPath)) {
$prevFileCount = (Get-ChildItem -Path $previousAppPath -Recurse -File).Count
Write-Host "Extracted $prevFileCount files from previous version for diff"
}
} else {
Write-Host "No app-$previousVersion-win-$arch.zip found in previous release - will generate full package"
Write-Host "This is expected for the first release after this fix."
}
}
} catch {
Write-Host "Could not fetch previous release: $_"
}
# --- Generate delta package using the script ---
if ($previousAppPath -and (Test-Path $previousAppPath) -and $previousVersion) {
Write-Host "Generating delta package from $previousVersion to $version..."
& $scriptPath `
-PreviousVersion $previousVersion `
-CurrentVersion $version `
-PreviousDir $previousAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
if ($LASTEXITCODE -ne 0) {
Write-Error "Generate-DeltaPackage.ps1 failed"
exit 1
}
} else {
Write-Host "No previous version available - generating full package..."
# Generate a "full" delta package (all files as "add")
& $scriptPath ` & $scriptPath `
-PreviousVersion "0.0.0" ` -PreviousVersion "0.0.0" `
-CurrentVersion $version ` -CurrentVersion $version `
-PreviousDir $currentAppPath ` -PreviousDir $currentAppPath `
-CurrentDir $currentAppPath ` -CurrentDir $currentAppPath `
-OutputDir $outputDir -OutputDir $outputDir
if ($LASTEXITCODE -ne 0) {
Write-Error "Generate-DeltaPackage.ps1 failed"
exit 1
}
}
# Clean up previous version extraction
if ($previousAppPath -and (Test-Path $previousAppPath)) {
Remove-Item -Path $previousAppPath -Recurse -Force -ErrorAction SilentlyContinue
}
# Display results
$updateZipPath = Join-Path $outputDir "update.zip"
if (Test-Path $updateZipPath) {
$sizeMB = [Math]::Round((Get-Item $updateZipPath).Length / 1MB, 2)
Write-Host "Created update.zip: $sizeMB MB"
}
shell: pwsh shell: pwsh
- name: Sign File Map - name: Upload Velopack Package
if: matrix.self_contained == true && matrix.arch == 'x64'
run: |
$outputDir = "delta-output"
$filesJsonPath = Join-Path $outputDir "files.json"
$signaturePath = Join-Path $outputDir "files.json.sig"
if (-not (Test-Path $filesJsonPath)) {
Write-Error "files.json not found at $filesJsonPath"
exit 1
}
$privateKeyPem = "${{ secrets.UPDATE_PRIVATE_KEY_PEM }}"
if ([string]::IsNullOrWhiteSpace($privateKeyPem)) {
Write-Warning "UPDATE_PRIVATE_KEY_PEM secret not configured - generating unsigned placeholder"
Set-Content -Path $signaturePath -Value "" -Encoding ASCII
exit 0
}
$privateKeyPath = Join-Path $env:RUNNER_TEMP "signing-key.pem"
Set-Content -Path $privateKeyPath -Value $privateKeyPem -Encoding ASCII
Add-Type -ReferencedAssemblies @("System.Security.Cryptography", "System.IO") -TypeDefinition @"
using System;
using System.IO;
using System.Security.Cryptography;
public class RsaSigner {
public static void Sign(string jsonPath, string keyPath, string sigPath) {
var jsonBytes = File.ReadAllBytes(jsonPath);
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(keyPath));
var sig = rsa.SignData(jsonBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
File.WriteAllText(sigPath, Convert.ToBase64String(sig));
}
}
"@
[RsaSigner]::Sign($filesJsonPath, $privateKeyPath, $signaturePath)
Remove-Item -Path $privateKeyPath -Force
Write-Host "Signed files.json -> files.json.sig"
shell: pwsh
- name: Upload Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64' if: matrix.self_contained == true && matrix.arch == 'x64'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release-delta-windows-x64 name: release-velopack-windows-x64
path: | path: |
delta-output/files.json velopack-output/*.nupkg
delta-output/files.json.sig velopack-output/releases.win.json
delta-output/update.zip velopack-output/assets.win.json
delta-output/app-*.zip velopack-output/RELEASES
if-no-files-found: error if-no-files-found: error
retention-days: 90 retention-days: 90
- name: Upload Installer - name: Upload Installer
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -889,10 +820,8 @@ jobs:
mkdir -p release-files mkdir -p release-files
# Copy installers and packages # Copy installers and packages
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \; find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
# Copy delta update files (files.json, files.json.sig, update.zip) # Copy Velopack release feed and update packages
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -exec cp -v {} release-files/ \; 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 app package for future delta generation (app-{version}-win-{arch}.zip)
find artifacts -type f -name "app-*.zip" -exec cp -v {} release-files/ \;
echo "" echo ""
echo "Files ready for release:" echo "Files ready for release:"
ls -lh release-files/ || echo "No files found in release-files" ls -lh release-files/ || echo "No files found in release-files"
@@ -927,9 +856,9 @@ jobs:
Installation: Double-click the .exe file and follow the wizard. Installation: Double-click the .exe file and follow the wizard.
### Incremental Update (Windows x64) ### Incremental Update (Windows x64)
- **files.json** - Update manifest listing changed files - **releases.win.json** - Velopack release feed consumed by the launcher update flow
- **files.json.sig** - RSA signature of the manifest - **LanMountainDesktop-<version>-full.nupkg** - full package
- **update.zip** - Archive containing changed files - **LanMountainDesktop-<version>-delta.nupkg** - delta package (when available)
Existing users: The app will automatically detect and apply the incremental update on next launch. Existing users: The app will automatically detect and apply the incremental update on next launch.

View File

@@ -0,0 +1,7 @@
# Checklist
- [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.

View File

@@ -0,0 +1,16 @@
# VeloPack Update Integration
## Goal
Switch incremental package generation and release assets to VeloPack native outputs while keeping Launcher as the update installer and rollback authority.
## 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.
## 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.

View File

@@ -0,0 +1,9 @@
# Tasks
- [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.

View File

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

View File

@@ -11,6 +11,8 @@ public sealed class ReleaseInfo
public required DateTime PublishedAt { get; init; } public required DateTime PublishedAt { get; init; }
public required List<ReleaseAsset> Assets { get; init; } public required List<ReleaseAsset> Assets { get; init; }
public string? Body { get; init; } public string? Body { get; init; }
public string? VelopackFeedUrl { get; init; }
public string? VelopackLegacyReleasesUrl { get; init; }
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,23 @@
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

@@ -91,11 +91,7 @@ internal static class Commands
"check" => updateEngine.CheckPendingUpdate(), "check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false), "apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(), "rollback" => updateEngine.RollbackLatest(),
"download" => await updateEngine.DownloadAsync( "download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false),
_ => new LauncherResult _ => new LauncherResult
{ {
Success = false, Success = false,
@@ -106,6 +102,35 @@ 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."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false);
}
private static LauncherResult ExecutePluginCommand( private static LauncherResult ExecutePluginCommand(
CommandContext context, CommandContext context,
PluginInstallerService pluginInstaller, PluginInstallerService pluginInstaller,

View File

@@ -104,7 +104,11 @@ internal sealed class UpdateCheckService
Name = a.Name ?? "", Name = a.Name ?? "",
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "", BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
Size = a.Size Size = a.Size
}).ToList() ?? [] }).ToList() ?? [],
VelopackFeedUrl = r.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, "releases.win.json", StringComparison.OrdinalIgnoreCase))?.BrowserDownloadUrl,
VelopackLegacyReleasesUrl = r.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, "RELEASES", StringComparison.OrdinalIgnoreCase))?.BrowserDownloadUrl
}).ToList() ?? []; }).ToList() ?? [];
} }

View File

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

View File

@@ -1,12 +1,13 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
mc:Ignorable="d" mc:Ignorable="d"
d:DesignWidth="600" d:DesignWidth="600"
d:DesignHeight="500" d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow" x:Class="LanMountainDesktop.Launcher.Views.LoadingDetailsWindow"
Title="阑山桌面 - 加载详情" Title="LanMountain Desktop - Loading Details"
Width="600" Width="600"
Height="500" Height="500"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
@@ -17,18 +18,17 @@
Icon="/Assets/logo.ico"> Icon="/Assets/logo.ico">
<Grid RowDefinitions="Auto,*,Auto,Auto"> <Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- 标题栏 -->
<Border Grid.Row="0" <Border Grid.Row="0"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="20,16"> Padding="20,16">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="正在启动阑山桌面" <TextBlock Text="Starting LanMountain Desktop"
FontSize="18" FontSize="18"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/> Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<TextBlock x:Name="SubtitleText" <TextBlock x:Name="SubtitleText"
Text="初始化系统组件..." Text="Initializing..."
FontSize="13" FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"/> Foreground="{DynamicResource TextFillColorSecondaryBrush}"/>
</StackPanel> </StackPanel>
@@ -46,7 +46,6 @@
</Grid> </Grid>
</Border> </Border>
<!-- 主要内容区域 -->
<Grid Grid.Row="1" Margin="16,12"> <Grid Grid.Row="1" Margin="16,12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
@@ -54,7 +53,6 @@
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- 整体进度条 -->
<ProgressBar x:Name="OverallProgressBar" <ProgressBar x:Name="OverallProgressBar"
Grid.Row="0" Grid.Row="0"
Height="8" Height="8"
@@ -64,14 +62,12 @@
CornerRadius="4" CornerRadius="4"
Margin="0,0,0,16"/> Margin="0,0,0,16"/>
<!-- 当前活动项 -->
<Border Grid.Row="1" <Border Grid.Row="1"
Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}" Background="{DynamicResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="8" CornerRadius="8"
Padding="16,12" Padding="16,12"
Margin="0,0,0,12"> Margin="0,0,0,12">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*"> <Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*">
<!-- 图标 -->
<Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0" <Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
Width="40" Width="40"
Height="40" Height="40"
@@ -88,23 +84,20 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</Border> </Border>
<!-- 名称 -->
<TextBlock x:Name="CurrentItemName" <TextBlock x:Name="CurrentItemName"
Grid.Row="0" Grid.Column="1" Grid.Row="0" Grid.Column="1"
Text="正在初始化..." Text="Initializing..."
FontSize="15" FontSize="15"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/> Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
<!-- 描述 -->
<TextBlock x:Name="CurrentItemDescription" <TextBlock x:Name="CurrentItemDescription"
Grid.Row="1" Grid.Column="1" Grid.Row="1" Grid.Column="1"
Text="准备加载系统组件" Text="Preparing components"
FontSize="13" FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,4,0,0"/> Margin="0,4,0,0"/>
<!-- 进度 -->
<Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0"> <Grid Grid.Row="2" Grid.Column="1" Margin="0,8,0,0">
<ProgressBar x:Name="CurrentItemProgress" <ProgressBar x:Name="CurrentItemProgress"
Height="4" Height="4"
@@ -116,15 +109,13 @@
</Grid> </Grid>
</Border> </Border>
<!-- 加载项列表 -->
<Border Grid.Row="2" <Border Grid.Row="2"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8"> CornerRadius="8">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<!-- 列表标题 -->
<Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto"> <Grid Grid.Row="0" Margin="12,8" ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" <TextBlock Grid.Column="0"
Text="加载项" Text="Loading Items"
FontSize="12" FontSize="12"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/> Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
@@ -135,22 +126,20 @@
Foreground="{DynamicResource TextFillColorSecondaryBrush}" Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Margin="0,0,4,0"/> Margin="0,0,4,0"/>
<TextBlock Grid.Column="2" <TextBlock Grid.Column="2"
Text="已完成" Text="Done"
FontSize="12" FontSize="12"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"/> Foreground="{DynamicResource TextFillColorTertiaryBrush}"/>
</Grid> </Grid>
<!-- 列表内容 -->
<ScrollViewer Grid.Row="1" <ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
Margin="8,0,8,8"> Margin="8,0,8,8">
<ItemsControl x:Name="LoadingItemsList"> <ItemsControl x:Name="LoadingItemsList">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate DataType="views:LoadingItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto,Auto" <Grid ColumnDefinitions="Auto,*,Auto,Auto"
Margin="4,3" Margin="4,3"
Opacity="{Binding Opacity}"> Opacity="{Binding Opacity}">
<!-- 状态图标 -->
<TextBlock Grid.Column="0" <TextBlock Grid.Column="0"
Text="{Binding StatusIcon}" Text="{Binding StatusIcon}"
FontSize="14" FontSize="14"
@@ -159,7 +148,6 @@
Margin="0,0,8,0" Margin="0,0,8,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- 名称 -->
<TextBlock Grid.Column="1" <TextBlock Grid.Column="1"
Text="{Binding Name}" Text="{Binding Name}"
FontSize="13" FontSize="13"
@@ -167,7 +155,6 @@
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- 进度 -->
<TextBlock Grid.Column="2" <TextBlock Grid.Column="2"
Text="{Binding ProgressText}" Text="{Binding ProgressText}"
FontSize="12" FontSize="12"
@@ -175,7 +162,6 @@
Margin="8,0" Margin="8,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- 类型标签 -->
<Border Grid.Column="3" <Border Grid.Column="3"
Background="{Binding TypeBackground}" Background="{Binding TypeBackground}"
CornerRadius="4" CornerRadius="4"
@@ -194,7 +180,6 @@
</Border> </Border>
</Grid> </Grid>
<!-- 错误信息区域 -->
<Border x:Name="ErrorPanel" <Border x:Name="ErrorPanel"
Grid.Row="2" Grid.Row="2"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}" Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
@@ -214,14 +199,13 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<TextBlock x:Name="ErrorText" <TextBlock x:Name="ErrorText"
Grid.Column="1" Grid.Column="1"
Text="加载过程中出现错误" Text="An error occurred while loading."
FontSize="13" FontSize="13"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" Foreground="{DynamicResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
</Grid> </Grid>
</Border> </Border>
<!-- 底部按钮 -->
<Border Grid.Row="3" <Border Grid.Row="3"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="16,12"> Padding="16,12">
@@ -234,12 +218,12 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button x:Name="DetailsButton" <Button x:Name="DetailsButton"
Content="查看详情" Content="Details"
Width="90" Width="90"
Height="32" Height="32"
FontSize="13"/> FontSize="13"/>
<Button x:Name="CancelButton" <Button x:Name="CancelButton"
Content="取消" Content="Cancel"
Width="90" Width="90"
Height="32" Height="32"
FontSize="13"/> FontSize="13"/>

View File

@@ -52,9 +52,7 @@ public sealed class UpdateWorkflowService
private const string LauncherDirectoryName = ".launcher"; private const string LauncherDirectoryName = ".launcher";
private const string UpdateDirectoryName = "update"; private const string UpdateDirectoryName = "update";
private const string IncomingDirectoryName = "incoming"; private const string IncomingDirectoryName = "incoming";
private const string DeltaManifestFileName = "files.json"; private const string VelopackReleasesFileName = "releases.win.json";
private const string DeltaSignatureFileName = "files.json.sig";
private const string DeltaArchiveFileName = "update.zip";
public UpdateWorkflowService(ISettingsFacadeService settingsFacade) public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{ {
@@ -81,8 +79,7 @@ public sealed class UpdateWorkflowService
} }
/// <summary> /// <summary>
/// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip). /// Checks whether a GitHub Release contains Velopack assets needed for incremental updates.
/// Also supports versioned filenames like files-{version}.json, delta-{old}-to-{new}.zip
/// </summary> /// </summary>
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release) public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
{ {
@@ -91,73 +88,13 @@ public sealed class UpdateWorkflowService
return false; return false;
} }
var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); 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));
// Check for exact matches first (preferred) return hasFeed && hasFull;
var hasExactManifest = assetNames.Contains(DeltaManifestFileName);
var hasExactSignature = assetNames.Contains(DeltaSignatureFileName);
var hasExactArchive = assetNames.Contains(DeltaArchiveFileName);
if (hasExactManifest && hasExactSignature && hasExactArchive)
{
return true;
}
// Check for versioned filenames (e.g., files-1.0.0.json, delta-0.9.9-to-1.0.0.zip)
var hasVersionedManifest = assetNames.Any(n => n.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
&& n.EndsWith(".json", StringComparison.OrdinalIgnoreCase));
var hasVersionedSignature = assetNames.Any(n => n.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
&& n.EndsWith(".sig", StringComparison.OrdinalIgnoreCase));
var hasVersionedArchive = assetNames.Any(n => n.StartsWith("delta-", StringComparison.OrdinalIgnoreCase)
&& n.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
return hasVersionedManifest && hasVersionedSignature && hasVersionedArchive;
} }
/// <summary> /// <summary>
/// Finds the best matching delta asset name from the release assets. /// Downloads Velopack release feed and package files to the Launcher's incoming directory.
/// Prefers exact matches, falls back to versioned filenames.
/// </summary>
private static string? FindDeltaAssetName(GitHubReleaseInfo release, string baseName)
{
if (release?.Assets is null)
{
return null;
}
// Try exact match first
var exactMatch = release.Assets.FirstOrDefault(a =>
string.Equals(a.Name, baseName, StringComparison.OrdinalIgnoreCase));
if (exactMatch != null)
{
return exactMatch.Name;
}
// Fall back to pattern matching
return baseName.ToLowerInvariant() switch
{
"files.json" => release.Assets
.Where(a => a.Name.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
&& a.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => a.Name.Length)
.FirstOrDefault()?.Name,
"files.json.sig" => release.Assets
.Where(a => a.Name.StartsWith("files-", StringComparison.OrdinalIgnoreCase)
&& a.Name.EndsWith(".sig", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => a.Name.Length)
.FirstOrDefault()?.Name,
"update.zip" => release.Assets
.Where(a => a.Name.StartsWith("delta-", StringComparison.OrdinalIgnoreCase)
&& a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => a.Name.Length)
.FirstOrDefault()?.Name,
_ => null
};
}
/// <summary>
/// Downloads the delta update package (files.json, files.json.sig, update.zip) from a GitHub Release
/// and places them in the Launcher's incoming directory for the Launcher to apply on next startup.
/// </summary> /// </summary>
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync( public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
UpdateCheckResult checkResult, UpdateCheckResult checkResult,
@@ -171,9 +108,11 @@ public sealed class UpdateWorkflowService
return new UpdateDownloadResult(false, null, "No update available for delta download."); return new UpdateDownloadResult(false, null, "No update available for delta download.");
} }
if (!IsDeltaUpdateAvailable(checkResult.Release)) var releasesFeedAsset = checkResult.Release.Assets.FirstOrDefault(a =>
string.Equals(a.Name, VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase));
if (releasesFeedAsset is null)
{ {
return new UpdateDownloadResult(false, null, "Release does not contain delta update assets."); return new UpdateDownloadResult(false, null, "Release does not contain releases.win.json.");
} }
var incomingDir = GetLauncherIncomingDirectory(); var incomingDir = GetLauncherIncomingDirectory();
@@ -191,55 +130,29 @@ public sealed class UpdateWorkflowService
var downloadSource = state.UpdateDownloadSource; var downloadSource = state.UpdateDownloadSource;
var downloadThreads = state.UpdateDownloadThreads; var downloadThreads = state.UpdateDownloadThreads;
// Find the actual asset names (support both exact and versioned filenames) var latestVersionText = checkResult.LatestVersionText.Trim();
var manifestAssetName = FindDeltaAssetName(checkResult.Release, DeltaManifestFileName); var targetPackages = checkResult.Release.Assets
var signatureAssetName = FindDeltaAssetName(checkResult.Release, DeltaSignatureFileName); .Where(a => a.Name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))
var archiveAssetName = FindDeltaAssetName(checkResult.Release, DeltaArchiveFileName); .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 (manifestAssetName is null || signatureAssetName is null || archiveAssetName is null) if (targetPackages.Count == 0)
{ {
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release."); return new UpdateDownloadResult(false, null, "No Velopack nupkg asset found for the target version.");
} }
// Build asset map with actual names from release var requiredAssets = new List<GitHubReleaseAsset> { releasesFeedAsset };
var assetMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) requiredAssets.AddRange(targetPackages);
{
[DeltaManifestFileName] = manifestAssetName,
[DeltaSignatureFileName] = signatureAssetName,
[DeltaArchiveFileName] = archiveAssetName
};
var requiredAssets = new Dictionary<string, GitHubReleaseAsset>(StringComparer.OrdinalIgnoreCase)
{
[DeltaManifestFileName] = null!,
[DeltaSignatureFileName] = null!,
[DeltaArchiveFileName] = null!
};
foreach (var asset in checkResult.Release.Assets)
{
// Match by actual asset name
foreach (var (key, actualName) in assetMap)
{
if (string.Equals(asset.Name, actualName, StringComparison.OrdinalIgnoreCase))
{
requiredAssets[key] = asset;
break;
}
}
}
if (requiredAssets.Any(kvp => kvp.Value is null))
{
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
}
var totalAssets = requiredAssets.Count; var totalAssets = requiredAssets.Count;
var completedAssets = 0; var completedAssets = 0;
foreach (var (name, asset) in requiredAssets) foreach (var asset in requiredAssets)
{ {
var destinationPath = Path.Combine(incomingDir, name); var destinationPath = Path.Combine(incomingDir, asset.Name);
// Skip if already downloaded and file exists // Skip if already downloaded and file exists
if (File.Exists(destinationPath)) if (File.Exists(destinationPath))
@@ -247,7 +160,7 @@ public sealed class UpdateWorkflowService
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken); var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase)) if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
{ {
AppLogger.Info("UpdateWorkflow", $"Delta asset {name} already downloaded with matching hash, skipping."); AppLogger.Info("UpdateWorkflow", $"Velopack asset {asset.Name} already downloaded with matching hash, skipping.");
completedAssets++; completedAssets++;
progress?.Report((double)completedAssets / totalAssets); progress?.Report((double)completedAssets / totalAssets);
continue; continue;
@@ -271,21 +184,21 @@ public sealed class UpdateWorkflowService
if (!result.Success) if (!result.Success)
{ {
// Clean up partially downloaded files // Clean up partially downloaded files
foreach (var file in requiredAssets.Keys) foreach (var file in requiredAssets.Select(a => a.Name))
{ {
try { File.Delete(Path.Combine(incomingDir, file)); } catch { } try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
} }
return new UpdateDownloadResult(false, null, $"Failed to download delta asset {name}: {result.ErrorMessage}"); return new UpdateDownloadResult(false, null, $"Failed to download Velopack asset {asset.Name}: {result.ErrorMessage}");
} }
completedAssets++; completedAssets++;
progress?.Report((double)completedAssets / totalAssets); progress?.Report((double)completedAssets / totalAssets);
} }
// Save state indicating a delta update is pending // Save state indicating a Velopack update is pending.
SaveState(state with SaveState(state with
{ {
PendingUpdateInstallerPath = Path.Combine(incomingDir, DeltaManifestFileName), PendingUpdateInstallerPath = Path.Combine(incomingDir, VelopackReleasesFileName),
PendingUpdateVersion = checkResult.LatestVersionText, PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null ? null
@@ -294,13 +207,13 @@ public sealed class UpdateWorkflowService
PendingUpdateSha256 = null PendingUpdateSha256 = null
}); });
AppLogger.Info("UpdateWorkflow", $"Delta update package downloaded to {incomingDir}. Will be applied by Launcher on next startup."); AppLogger.Info("UpdateWorkflow", $"Velopack update payload downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
return new UpdateDownloadResult(true, Path.Combine(incomingDir, DeltaManifestFileName), null); return new UpdateDownloadResult(true, Path.Combine(incomingDir, VelopackReleasesFileName), null);
} }
/// <summary> /// <summary>
/// Checks whether the pending update is a delta update (files.json in incoming dir) vs a full installer. /// Checks whether the pending update is managed by Launcher incoming payload.
/// </summary> /// </summary>
public bool IsPendingDeltaUpdate() public bool IsPendingDeltaUpdate()
{ {
@@ -311,8 +224,8 @@ public sealed class UpdateWorkflowService
return false; return false;
} }
// Delta updates are identified by the manifest file path // Velopack updates are identified by the releases feed path.
return pendingPath.EndsWith(DeltaManifestFileName, StringComparison.OrdinalIgnoreCase) return pendingPath.EndsWith(VelopackReleasesFileName, StringComparison.OrdinalIgnoreCase)
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase); || pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
} }

View File

@@ -194,3 +194,9 @@ This repository is organized around a desktop host app plus a host-side plugin e
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, incremental updates, and plugin installation. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation. **Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, incremental updates, and plugin installation. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation.
The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence. The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
## VeloPack Integration Note
- Incremental package build/publish has moved to VeloPack native assets (
eleases.win.json + *.nupkg).
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.

View File

@@ -166,3 +166,10 @@ Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`. For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
**Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation. **Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation.
## VeloPack Release Assets
- Windows incremental release packaging now uses VeloPack native outputs (
eleases.win.json, *.nupkg).
- Launcher still performs update apply/rollback; VeloPack is used for package generation.
- Legacy delta script flow is retained behind a disabled fallback switch in CI.

View File

@@ -442,3 +442,10 @@ private static void EnsurePathWithinRoot(string targetPath, string rootPath)
- [Launcher 架构文档](LAUNCHER.md) - [Launcher 架构文档](LAUNCHER.md)
- [构建和部署指南](BUILD_AND_DEPLOY.md) - [构建和部署指南](BUILD_AND_DEPLOY.md)
- [故障排除指南](TROUBLESHOOTING.md) - [故障排除指南](TROUBLESHOOTING.md)
## VeloPack Packaging (Current)
- Release pipeline now produces VeloPack native assets (
eleases.win.json, *.nupkg, RELEASES).
- Launcher remains the installer and rollback authority; only package generation moved to VeloPack.
- Legacy iles.json + update.zip generation remains available only as a disabled fallback path in CI.