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:
fetch-depth: 0
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
uses: actions/setup-dotnet@v4

View File

@@ -20,6 +20,7 @@ env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1
ENABLE_LEGACY_DELTA_FALLBACK: 'false'
jobs:
prepare:
@@ -109,7 +110,7 @@ jobs:
Write-Host "Publishing Launcher with AOT for Windows $arch..."
# AOT 单文件发布
# AOT publish
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./$launcherPublishDir `
@@ -127,7 +128,7 @@ jobs:
exit 1
}
# 显示发布结果
# 鏄剧ず鍙戝竷缁撴灉
Write-Host "Launcher published to: $launcherPublishDir"
$exeFile = Get-ChildItem -Path $launcherPublishDir -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
@@ -135,7 +136,7 @@ jobs:
Write-Host "Launcher executable: $($exeFile.Name) ($size MB)"
}
# 清理不必要的文件AOT 单文件应该只有一个 exe
# Warn if unexpected extra files are produced
$files = Get-ChildItem -Path $launcherPublishDir -File
if ($files.Count -gt 1) {
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"
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'
run: |
$version = "${{ needs.prepare.outputs.version }}"
@@ -325,21 +361,33 @@ jobs:
$publishDir = "publish/windows-$arch"
$appDir = "app-$version"
$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
vpk pack `
--packId LanMountainDesktop `
--packVersion $version `
--packDir $currentAppPath `
--mainExe LanMountainDesktop.exe `
--outputDir $outputDir `
--channel win `
--noPortable
# 创建 app-{version}-win-{arch}.zip 供后续版本作为旧版本对比
$appZipPath = Join-Path $outputDir "app-$version-win-$arch.zip"
Write-Host "Creating app-$version-win-$arch.zip..."
Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal
if ($LASTEXITCODE -ne 0) {
Write-Error "Velopack packaging failed."
exit 1
}
$sizeMB = [Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)
Write-Host "Created app-$version-win-$arch.zip: $sizeMB MB"
Get-ChildItem -Path $outputDir -File | Select-Object Name,Length
shell: pwsh
- name: Generate Delta Package
if: matrix.self_contained == true && matrix.arch == 'x64'
- 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 }}"
@@ -350,143 +398,26 @@ jobs:
$scriptPath = "scripts/Generate-DeltaPackage.ps1"
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 `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-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"
}
& $scriptPath `
-PreviousVersion "0.0.0" `
-CurrentVersion $version `
-PreviousDir $currentAppPath `
-CurrentDir $currentAppPath `
-OutputDir $outputDir
shell: pwsh
- name: Sign File Map
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
- name: Upload Velopack Package
if: matrix.self_contained == true && matrix.arch == 'x64'
uses: actions/upload-artifact@v4
with:
name: release-delta-windows-x64
name: release-velopack-windows-x64
path: |
delta-output/files.json
delta-output/files.json.sig
delta-output/update.zip
delta-output/app-*.zip
velopack-output/*.nupkg
velopack-output/releases.win.json
velopack-output/assets.win.json
velopack-output/RELEASES
if-no-files-found: error
retention-days: 90
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
@@ -889,10 +820,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 delta update files (files.json, files.json.sig, update.zip)
find artifacts -type f \( -name "files.json" -o -name "files.json.sig" -o -name "update.zip" \) -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/ \;
# 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/ \;
echo ""
echo "Files ready for release:"
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.
### Incremental Update (Windows x64)
- **files.json** - Update manifest listing changed files
- **files.json.sig** - RSA signature of the manifest
- **update.zip** - Archive containing changed files
- **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)
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(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
[JsonSerializable(typeof(VelopackReleaseFeed))]
[JsonSerializable(typeof(VelopackReleaseAsset))]
[JsonSerializable(typeof(List<VelopackReleaseAsset>))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -11,6 +11,8 @@ 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

@@ -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(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => 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),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
_ => new LauncherResult
{
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(
CommandContext context,
PluginInstallerService pluginInstaller,

View File

@@ -104,7 +104,11 @@ internal sealed class UpdateCheckService
Name = a.Name ?? "",
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
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() ?? [];
}

View File

@@ -14,6 +14,7 @@ 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;
@@ -33,6 +34,16 @@ 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);
@@ -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)
{
Directory.CreateDirectory(_incomingRoot);
@@ -115,6 +167,12 @@ 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);
@@ -573,7 +631,8 @@ internal sealed class UpdateEngineService
{
Path.Combine(_incomingRoot, SignedFileMapName),
Path.Combine(_incomingRoot, SignatureFileName),
Path.Combine(_incomingRoot, ArchiveFileName)
Path.Combine(_incomingRoot, ArchiveFileName),
Path.Combine(_incomingRoot, VelopackReleasesFileName)
})
{
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)
@@ -654,6 +724,307 @@ 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

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

View File

@@ -52,9 +52,7 @@ public sealed class UpdateWorkflowService
private const string LauncherDirectoryName = ".launcher";
private const string UpdateDirectoryName = "update";
private const string IncomingDirectoryName = "incoming";
private const string DeltaManifestFileName = "files.json";
private const string DeltaSignatureFileName = "files.json.sig";
private const string DeltaArchiveFileName = "update.zip";
private const string VelopackReleasesFileName = "releases.win.json";
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
{
@@ -81,8 +79,7 @@ public sealed class UpdateWorkflowService
}
/// <summary>
/// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip).
/// Also supports versioned filenames like files-{version}.json, delta-{old}-to-{new}.zip
/// Checks whether a GitHub Release contains Velopack assets needed for incremental updates.
/// </summary>
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
{
@@ -91,73 +88,13 @@ public sealed class UpdateWorkflowService
return false;
}
var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
// Check for exact matches first (preferred)
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>
/// Finds the best matching delta asset name from the release assets.
/// 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
};
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;
}
/// <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.
/// Downloads Velopack release feed and package files to the Launcher's incoming directory.
/// </summary>
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
UpdateCheckResult checkResult,
@@ -171,9 +108,11 @@ public sealed class UpdateWorkflowService
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();
@@ -191,55 +130,29 @@ public sealed class UpdateWorkflowService
var downloadSource = state.UpdateDownloadSource;
var downloadThreads = state.UpdateDownloadThreads;
// Find the actual asset names (support both exact and versioned filenames)
var manifestAssetName = FindDeltaAssetName(checkResult.Release, DeltaManifestFileName);
var signatureAssetName = FindDeltaAssetName(checkResult.Release, DeltaSignatureFileName);
var archiveAssetName = FindDeltaAssetName(checkResult.Release, DeltaArchiveFileName);
if (manifestAssetName is null || signatureAssetName is null || archiveAssetName is null)
{
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
}
// Build asset map with actual names from release
var assetMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[DeltaManifestFileName] = manifestAssetName,
[DeltaSignatureFileName] = signatureAssetName,
[DeltaArchiveFileName] = archiveAssetName
};
var requiredAssets = new Dictionary<string, GitHubReleaseAsset>(StringComparer.OrdinalIgnoreCase)
{
[DeltaManifestFileName] = null!,
[DeltaSignatureFileName] = null!,
[DeltaArchiveFileName] = null!
};
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();
foreach (var asset in checkResult.Release.Assets)
if (targetPackages.Count == 0)
{
// Match by actual asset name
foreach (var (key, actualName) in assetMap)
{
if (string.Equals(asset.Name, actualName, StringComparison.OrdinalIgnoreCase))
{
requiredAssets[key] = asset;
break;
}
}
return new UpdateDownloadResult(false, null, "No Velopack nupkg asset found for the target version.");
}
if (requiredAssets.Any(kvp => kvp.Value is null))
{
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
}
var requiredAssets = new List<GitHubReleaseAsset> { releasesFeedAsset };
requiredAssets.AddRange(targetPackages);
var totalAssets = requiredAssets.Count;
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
if (File.Exists(destinationPath))
@@ -247,7 +160,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", $"Delta asset {name} already downloaded with matching hash, skipping.");
AppLogger.Info("UpdateWorkflow", $"Velopack asset {asset.Name} already downloaded with matching hash, skipping.");
completedAssets++;
progress?.Report((double)completedAssets / totalAssets);
continue;
@@ -271,21 +184,21 @@ public sealed class UpdateWorkflowService
if (!result.Success)
{
// 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 { }
}
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++;
progress?.Report((double)completedAssets / totalAssets);
}
// Save state indicating a delta update is pending
// Save state indicating a Velopack update is pending.
SaveState(state with
{
PendingUpdateInstallerPath = Path.Combine(incomingDir, DeltaManifestFileName),
PendingUpdateInstallerPath = Path.Combine(incomingDir, VelopackReleasesFileName),
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null
@@ -294,13 +207,13 @@ public sealed class UpdateWorkflowService
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>
/// 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>
public bool IsPendingDeltaUpdate()
{
@@ -311,8 +224,8 @@ public sealed class UpdateWorkflowService
return false;
}
// Delta updates are identified by the manifest file path
return pendingPath.EndsWith(DeltaManifestFileName, StringComparison.OrdinalIgnoreCase)
// Velopack updates are identified by the releases feed path.
return pendingPath.EndsWith(VelopackReleasesFileName, 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.
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`.
**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)
- [构建和部署指南](BUILD_AND_DEPLOY.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.