diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 9e64933..65a5201 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aff2ac8..1ede1d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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--full.nupkg** - full package + - **LanMountainDesktop--delta.nupkg** - delta package (when available) Existing users: The app will automatically detect and apply the incremental update on next launch. diff --git a/.trae/specs/velopack-update-integration/checklist.md b/.trae/specs/velopack-update-integration/checklist.md new file mode 100644 index 0000000..04b7278 --- /dev/null +++ b/.trae/specs/velopack-update-integration/checklist.md @@ -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. diff --git a/.trae/specs/velopack-update-integration/spec.md b/.trae/specs/velopack-update-integration/spec.md new file mode 100644 index 0000000..cb687c0 --- /dev/null +++ b/.trae/specs/velopack-update-integration/spec.md @@ -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. diff --git a/.trae/specs/velopack-update-integration/tasks.md b/.trae/specs/velopack-update-integration/tasks.md new file mode 100644 index 0000000..2799d02 --- /dev/null +++ b/.trae/specs/velopack-update-integration/tasks.md @@ -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. diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 7b02ce9..210d0e0 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -20,4 +20,7 @@ namespace LanMountainDesktop.Launcher; [JsonSerializable(typeof(GitHubRelease))] [JsonSerializable(typeof(GitHubAsset))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(VelopackReleaseFeed))] +[JsonSerializable(typeof(VelopackReleaseAsset))] +[JsonSerializable(typeof(List))] internal sealed partial class AppJsonContext : JsonSerializerContext; diff --git a/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs b/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs index b74348a..15fcb46 100644 --- a/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs +++ b/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs @@ -11,6 +11,8 @@ public sealed class ReleaseInfo public required DateTime PublishedAt { get; init; } public required List Assets { get; init; } public string? Body { get; init; } + public string? VelopackFeedUrl { get; init; } + public string? VelopackLegacyReleasesUrl { get; init; } } /// diff --git a/LanMountainDesktop.Launcher/Models/VelopackModels.cs b/LanMountainDesktop.Launcher/Models/VelopackModels.cs new file mode 100644 index 0000000..7d568e1 --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/VelopackModels.cs @@ -0,0 +1,23 @@ +namespace LanMountainDesktop.Launcher.Models; + +internal sealed class VelopackReleaseFeed +{ + public List 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; } +} diff --git a/LanMountainDesktop.Launcher/Services/Commands.cs b/LanMountainDesktop.Launcher/Services/Commands.cs index 4c038a2..14289ff 100644 --- a/LanMountainDesktop.Launcher/Services/Commands.cs +++ b/LanMountainDesktop.Launcher/Services/Commands.cs @@ -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 DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine) + { + var releasesUrl = context.GetOption("releases-url"); + if (!string.IsNullOrWhiteSpace(releasesUrl)) + { + var packageUrls = new List(); + 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, diff --git a/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs b/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs index 5f4e3dd..9a17d4b 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs @@ -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() ?? []; } diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index a637a80..32d2aa8 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -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 DownloadVelopackAsync( + string releasesJsonUrl, + IReadOnlyList 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 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 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)); diff --git a/LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml b/LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml index 13ea986..f98a217 100644 --- a/LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml +++ b/LanMountainDesktop.Launcher/Views/LoadingDetailsWindow.axaml @@ -1,12 +1,13 @@ - - - - @@ -46,7 +46,6 @@ - @@ -54,7 +53,6 @@ - - - - - - - - @@ -135,22 +126,20 @@ Foreground="{DynamicResource TextFillColorSecondaryBrush}" Margin="0,0,4,0"/> - - - + - - - - - - @@ -234,12 +218,12 @@ VerticalAlignment="Center"/>