mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
changed.velopack,试试rust
This commit is contained in:
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -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
|
||||
|
||||
227
.github/workflows/release.yml
vendored
227
.github/workflows/release.yml
vendored
@@ -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.
|
||||
|
||||
|
||||
7
.trae/specs/velopack-update-integration/checklist.md
Normal file
7
.trae/specs/velopack-update-integration/checklist.md
Normal 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.
|
||||
16
.trae/specs/velopack-update-integration/spec.md
Normal file
16
.trae/specs/velopack-update-integration/spec.md
Normal 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.
|
||||
9
.trae/specs/velopack-update-integration/tasks.md
Normal file
9
.trae/specs/velopack-update-integration/tasks.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
23
LanMountainDesktop.Launcher/Models/VelopackModels.cs
Normal file
23
LanMountainDesktop.Launcher/Models/VelopackModels.cs
Normal 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; }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user