From 2f0c178df248218b4bbf88594bdb41d340301b2b Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 16 Apr 2026 01:59:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BF=80=E8=BF=9B=E7=9A=84=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 111 + .trae/specs/launcher-upgrade/checklist.md | 8 + .trae/specs/launcher-upgrade/spec.md | 54 + .trae/specs/launcher-upgrade/tasks.md | 12 + LanMountainDesktop.Launcher/App.axaml | 8 + LanMountainDesktop.Launcher/App.axaml.cs | 50 + LanMountainDesktop.Launcher/CommandContext.cs | 79 + .../LanMountainDesktop.Launcher.csproj | 24 + .../LauncherRuntimeContext.cs | 6 + .../Models/LauncherResult.cs | 42 + .../Models/ReleaseInfo.cs | 24 + .../Models/UpdateChannel.cs | 17 + .../Models/UpdateCheckResult.cs | 13 + .../Models/UpdateModels.cs | 55 + LanMountainDesktop.Launcher/NativeMethods.txt | 1 + LanMountainDesktop.Launcher/Program.cs | 191 + .../Properties/launchSettings.json | 29 + .../Services/Commands.cs | 174 + .../Services/DeploymentLocator.cs | 160 + .../Services/IOobeStep.cs | 6 + .../Services/ISplashStageReporter.cs | 6 + .../Services/LauncherFlowCoordinator.cs | 204 + .../Services/OobeStateService.cs | 29 + .../Services/PluginInstallerService.cs | 152 +- .../Services/PluginUpgradeQueueService.cs | 97 + .../Services/UpdateCheckService.cs | 168 + .../Services/UpdateEngineService.cs | 512 + .../Views/OobeWindow.axaml | 22 + .../Views/OobeWindow.axaml.cs | 27 + .../Views/SplashWindow.axaml | 17 + .../Views/SplashWindow.axaml.cs | 12 + ...ountainDesktop.PluginsInstallHelper.csproj | 14 - LanMountainDesktop.slnx | 2 +- LanMountainDesktop/LanMountainDesktop.csproj | 14 +- .../Properties/launchSettings.json | 21 + ...stallHelperClient.cs => LauncherClient.cs} | 46 +- .../installer/LanMountainDesktop.iss | 28 +- .../plugins/PluginMarketInstallService.cs | 20 +- README.md | 12 +- docs/ARCHITECTURE.md | 130 +- docs/BUILD_AND_DEPLOY.md | 335 + docs/DEVELOPMENT.md | 87 + docs/LAUNCHER.md | 549 + docs/PLUGIN_DEVELOPMENT.md | 686 + docs/TROUBLESHOOTING.md | 644 + docs/UPDATE_SYSTEM.md | 444 + docs/ai/CODEBASE_MAP.md | 2 +- scripts/Generate-DeltaPackage.ps1 | 184 + scripts/Sign-FileMap.ps1 | 65 + tmp.json | 20650 ++++++++++++++++ 50 files changed, 26059 insertions(+), 184 deletions(-) create mode 100644 .trae/specs/launcher-upgrade/checklist.md create mode 100644 .trae/specs/launcher-upgrade/spec.md create mode 100644 .trae/specs/launcher-upgrade/tasks.md create mode 100644 LanMountainDesktop.Launcher/App.axaml create mode 100644 LanMountainDesktop.Launcher/App.axaml.cs create mode 100644 LanMountainDesktop.Launcher/CommandContext.cs create mode 100644 LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj create mode 100644 LanMountainDesktop.Launcher/LauncherRuntimeContext.cs create mode 100644 LanMountainDesktop.Launcher/Models/LauncherResult.cs create mode 100644 LanMountainDesktop.Launcher/Models/ReleaseInfo.cs create mode 100644 LanMountainDesktop.Launcher/Models/UpdateChannel.cs create mode 100644 LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs create mode 100644 LanMountainDesktop.Launcher/Models/UpdateModels.cs create mode 100644 LanMountainDesktop.Launcher/NativeMethods.txt create mode 100644 LanMountainDesktop.Launcher/Program.cs create mode 100644 LanMountainDesktop.Launcher/Properties/launchSettings.json create mode 100644 LanMountainDesktop.Launcher/Services/Commands.cs create mode 100644 LanMountainDesktop.Launcher/Services/DeploymentLocator.cs create mode 100644 LanMountainDesktop.Launcher/Services/IOobeStep.cs create mode 100644 LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs create mode 100644 LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs create mode 100644 LanMountainDesktop.Launcher/Services/OobeStateService.cs rename LanMountainDesktop.PluginsInstallHelper/Program.cs => LanMountainDesktop.Launcher/Services/PluginInstallerService.cs (52%) create mode 100644 LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs create mode 100644 LanMountainDesktop.Launcher/Services/UpdateCheckService.cs create mode 100644 LanMountainDesktop.Launcher/Services/UpdateEngineService.cs create mode 100644 LanMountainDesktop.Launcher/Views/OobeWindow.axaml create mode 100644 LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs create mode 100644 LanMountainDesktop.Launcher/Views/SplashWindow.axaml create mode 100644 LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs delete mode 100644 LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj create mode 100644 LanMountainDesktop/Properties/launchSettings.json rename LanMountainDesktop/Services/{PluginsInstallHelperClient.cs => LauncherClient.cs} (71%) create mode 100644 docs/BUILD_AND_DEPLOY.md create mode 100644 docs/LAUNCHER.md create mode 100644 docs/PLUGIN_DEVELOPMENT.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/UPDATE_SYSTEM.md create mode 100644 scripts/Generate-DeltaPackage.ps1 create mode 100644 scripts/Sign-FileMap.ps1 create mode 100644 tmp.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc10782..b7831b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,6 +140,48 @@ jobs: Write-Host "Self-contained: $selfContained" shell: pwsh + - name: Restructure for Launcher + run: | + $version = "${{ needs.prepare.outputs.version }}" + $arch = "${{ matrix.arch }}" + $selfContained = "${{ matrix.self_contained }}" -eq "true" + $publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" } + $appDir = "app-$version" + + Write-Host "重组目录结构为 Launcher 模式..." + Write-Host "版本: $version" + Write-Host "发布目录: $publishDir" + + # 创建新的目录结构 + $newStructure = "publish-launcher/windows-$arch" + New-Item -ItemType Directory -Path $newStructure -Force | Out-Null + + # 移动主程序到 app-{version} 子目录 + $appPath = Join-Path $newStructure $appDir + Move-Item -Path $publishDir -Destination $appPath -Force + + # Launcher 应该在根目录 + # 注意: Launcher 已经通过 CopyLauncherToPublish 目标复制到了 Launcher/ 子目录 + $launcherSource = Join-Path $appPath "Launcher" + if (Test-Path $launcherSource) { + Write-Host "移动 Launcher 到根目录..." + Get-ChildItem -Path $launcherSource | Move-Item -Destination $newStructure -Force + Remove-Item -Path $launcherSource -Recurse -Force + } else { + Write-Warning "Launcher 目录不存在: $launcherSource" + } + + # 创建 .current 标记 + New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null + + Write-Host "新目录结构:" + Get-ChildItem -Path $newStructure -Recurse -Depth 2 | Select-Object FullName + + # 替换原发布目录 + Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue + Move-Item -Path $newStructure -Destination $publishDir -Force + shell: pwsh + - name: Install Inno Setup run: choco install innosetup -y --no-progress shell: pwsh @@ -242,6 +284,75 @@ jobs: Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB" shell: pwsh + - name: Generate Delta Package + if: matrix.self_contained == true && matrix.arch == 'x64' + run: | + $version = "${{ needs.prepare.outputs.version }}" + $publishDir = "publish/windows-${{ matrix.arch }}" + $appDir = "app-$version" + $currentAppPath = Join-Path $publishDir $appDir + + Write-Host "生成增量更新包..." + Write-Host "当前版本: $version" + + # TODO: 从上一个 Release 下载并解压以生成增量包 + # 这里先生成完整的 files.json + + $outputDir = "delta-output" + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null + + # 生成 files.json (完整文件清单) + $files = Get-ChildItem -Path $currentAppPath -Recurse -File + $fileEntries = @() + + foreach ($file in $files) { + $relativePath = $file.FullName.Substring($currentAppPath.Length).TrimStart('\', '/') + $relativePath = $relativePath.Replace('\', '/') + + # 跳过标记文件 + if ($relativePath -match '^\.(current|partial|destroy)$') { + continue + } + + $hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower() + + $fileEntries += @{ + Path = $relativePath + Action = "add" + Sha256 = $hash + Size = $file.Length + ArchivePath = $relativePath + } + } + + $filesJson = @{ + FromVersion = $null + ToVersion = $version + GeneratedAt = (Get-Date).ToUniversalTime().ToString("o") + Files = $fileEntries + } | ConvertTo-Json -Depth 10 + + $filesJsonPath = Join-Path $outputDir "files-$version.json" + $filesJson | Set-Content -Path $filesJsonPath -Encoding UTF8 + + Write-Host "生成文件清单: $filesJsonPath" + Write-Host "文件数量: $($fileEntries.Count)" + + # 创建完整应用包 (app-{version}.zip) + $appZipPath = Join-Path $outputDir "app-$version.zip" + Compress-Archive -Path "$currentAppPath\*" -DestinationPath $appZipPath -CompressionLevel Optimal + + Write-Host "创建应用包: $appZipPath" + Write-Host "包大小: $([Math]::Round((Get-Item $appZipPath).Length / 1MB, 2)) MB" + shell: pwsh + + - name: Upload Delta Package + if: matrix.self_contained == true && matrix.arch == 'x64' + uses: actions/upload-artifact@v4 + with: + name: delta-package-windows-${{ matrix.arch }} + path: delta-output/* + - name: Upload Installer uses: actions/upload-artifact@v4 with: diff --git a/.trae/specs/launcher-upgrade/checklist.md b/.trae/specs/launcher-upgrade/checklist.md new file mode 100644 index 0000000..d496572 --- /dev/null +++ b/.trae/specs/launcher-upgrade/checklist.md @@ -0,0 +1,8 @@ +# Launcher Upgrade Checklist + +- [x] Build passes for `LanMountainDesktop.Launcher`. +- [x] `update check` command returns structured JSON result. +- [x] `plugin update` command returns structured JSON result. +- [x] Legacy plugin install arguments still execute. +- [x] OOBE and splash are implemented as separate windows. +- [x] Update and rollback logic use version directory markers. diff --git a/.trae/specs/launcher-upgrade/spec.md b/.trae/specs/launcher-upgrade/spec.md new file mode 100644 index 0000000..524f535 --- /dev/null +++ b/.trae/specs/launcher-upgrade/spec.md @@ -0,0 +1,54 @@ +# Launcher Upgrade Spec + +## Goal + +Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for: + +- OOBE first-run entry +- startup splash window +- silent/incremental/rollback update +- plugin install/update + +## Scope (Phase 1) + +- Avalonia GUI launcher with two windows: + - `OOBEWindow` (first run only) + - `SplashWindow` (every launch) +- Default command `launch` +- CLI commands: + - `update check|download|apply|rollback` + - `plugin install|update` +- Legacy compatibility: + - `--source --plugins-dir --result` still works for plugin install + +## Update Behavior + +- ClassIsland-style deployment folders: + - `app--/` + - marker files `.current`, `.partial`, `.destroy` +- Signed file map: + - `files.json` + - `files.json.sig` + - `public-key.pem` +- Incremental update: + - `replace` from archive + - `reuse` from current deployment + - `delete` skip file in target deployment +- Rollback: + - snapshot metadata is written before apply + - automatic rollback on apply failure + - manual rollback via command + +## OOBE and Splash + +- OOBE is independent from splash. +- OOBE shows only: + - welcome text: `欢迎使用阑山桌面` + - arrow button for continue +- Splash shows only: + - app name: `阑山桌面` + +## Extensibility + +- `IOobeStep` for future multi-step OOBE +- `ISplashStageReporter` for future startup progress visualization diff --git a/.trae/specs/launcher-upgrade/tasks.md b/.trae/specs/launcher-upgrade/tasks.md new file mode 100644 index 0000000..bd470e2 --- /dev/null +++ b/.trae/specs/launcher-upgrade/tasks.md @@ -0,0 +1,12 @@ +# Launcher Upgrade Tasks + +- [x] Convert `LanMountainDesktop.Launcher` to Avalonia launcher entry. +- [x] Add OOBE window with first-run marker handling. +- [x] Add splash window for every startup. +- [x] Implement unified command parsing with default `launch`. +- [x] Keep legacy plugin install args compatibility. +- [x] Add plugin pending upgrade queue processing. +- [x] Implement incremental update apply with signed file map. +- [x] Implement snapshot-based rollback and manual rollback command. +- [x] Add update check/download/apply/rollback CLI commands. +- [x] Add launcher spec files under `.trae/specs/launcher-upgrade/`. diff --git a/LanMountainDesktop.Launcher/App.axaml b/LanMountainDesktop.Launcher/App.axaml new file mode 100644 index 0000000..cbb832e --- /dev/null +++ b/LanMountainDesktop.Launcher/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs new file mode 100644 index 0000000..8c1d949 --- /dev/null +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -0,0 +1,50 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Services; + +namespace LanMountainDesktop.Launcher; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var context = LauncherRuntimeContext.Current; + var appRoot = Commands.ResolveAppRoot(context); + var deploymentLocator = new DeploymentLocator(appRoot); + + // TODO: 从配置读取 GitHub 仓库信息 + var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop"); + + var coordinator = new LauncherFlowCoordinator( + context, + deploymentLocator, + new OobeStateService(appRoot), + new UpdateEngineService(deploymentLocator), + updateCheckService, + new PluginInstallerService()); + + _ = RunCoordinatorAsync(desktop, coordinator); + } + + base.OnFrameworkInitializationCompleted(); + } + + private static async Task RunCoordinatorAsync( + IClassicDesktopStyleApplicationLifetime desktop, + LauncherFlowCoordinator coordinator) + { + var result = await coordinator.RunAsync().ConfigureAwait(false); + await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false); + Environment.ExitCode = result.Success ? 0 : 1; + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); + } +} diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs new file mode 100644 index 0000000..29dccff --- /dev/null +++ b/LanMountainDesktop.Launcher/CommandContext.cs @@ -0,0 +1,79 @@ +using System.Globalization; + +namespace LanMountainDesktop.Launcher; + +internal sealed class CommandContext +{ + public string Command { get; } + + public string SubCommand { get; } + + public IReadOnlyDictionary Options { get; } + + public bool IsLegacyPluginInstall => + Options.ContainsKey("source") && + Options.ContainsKey("plugins-dir") && + Options.ContainsKey("result"); + + private CommandContext(string command, string subCommand, Dictionary options) + { + Command = command; + SubCommand = subCommand; + Options = options; + } + + public static CommandContext FromArgs(string[] args) + { + var options = ParseOptions(args); + var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal) + ? args[0] + : "launch"; + var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal) + ? args[1] + : string.Empty; + + return new CommandContext(command, subCommand, options); + } + + public string? GetOption(string key) + { + return Options.TryGetValue(key, out var value) ? value : null; + } + + public int GetIntOption(string key, int fallback) + { + var raw = GetOption(key); + return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : fallback; + } + + private static Dictionary ParseOptions(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < args.Length; i++) + { + var current = args[i]; + if (!current.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = current[2..]; + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + values[key] = args[++i]; + continue; + } + + values[key] = "true"; + } + + return values; + } +} diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj new file mode 100644 index 0000000..71c7f18 --- /dev/null +++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj @@ -0,0 +1,24 @@ + + + WinExe + net10.0 + enable + enable + 1.0.0 + $(Version) + true + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop.Launcher/LauncherRuntimeContext.cs b/LanMountainDesktop.Launcher/LauncherRuntimeContext.cs new file mode 100644 index 0000000..25ef5db --- /dev/null +++ b/LanMountainDesktop.Launcher/LauncherRuntimeContext.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.Launcher; + +internal static class LauncherRuntimeContext +{ + public static CommandContext Current { get; set; } = CommandContext.FromArgs([]); +} diff --git a/LanMountainDesktop.Launcher/Models/LauncherResult.cs b/LanMountainDesktop.Launcher/Models/LauncherResult.cs new file mode 100644 index 0000000..99c50de --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/LauncherResult.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.Launcher.Models; + +internal sealed class LauncherResult +{ + [JsonPropertyName("success")] + public bool Success { get; init; } + + [JsonPropertyName("stage")] + public string Stage { get; init; } = string.Empty; + + [JsonPropertyName("code")] + public string Code { get; init; } = "ok"; + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; + + [JsonPropertyName("currentVersion")] + public string? CurrentVersion { get; init; } + + [JsonPropertyName("targetVersion")] + public string? TargetVersion { get; init; } + + [JsonPropertyName("rolledBackTo")] + public string? RolledBackTo { get; init; } + + [JsonPropertyName("details")] + public Dictionary Details { get; init; } = []; + + [JsonPropertyName("installedPackagePath")] + public string? InstalledPackagePath { get; init; } + + [JsonPropertyName("manifestId")] + public string? ManifestId { get; init; } + + [JsonPropertyName("manifestName")] + public string? ManifestName { get; init; } + + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; init; } +} diff --git a/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs b/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs new file mode 100644 index 0000000..b74348a --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/ReleaseInfo.cs @@ -0,0 +1,24 @@ +namespace LanMountainDesktop.Launcher.Models; + +/// +/// GitHub Release 信息 +/// +public sealed class ReleaseInfo +{ + public required string TagName { get; init; } + public required string Name { get; init; } + public required bool Prerelease { get; init; } + public required DateTime PublishedAt { get; init; } + public required List Assets { get; init; } + public string? Body { get; init; } +} + +/// +/// Release 资源文件 +/// +public sealed class ReleaseAsset +{ + public required string Name { get; init; } + public required string BrowserDownloadUrl { get; init; } + public required long Size { get; init; } +} diff --git a/LanMountainDesktop.Launcher/Models/UpdateChannel.cs b/LanMountainDesktop.Launcher/Models/UpdateChannel.cs new file mode 100644 index 0000000..01a45ed --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/UpdateChannel.cs @@ -0,0 +1,17 @@ +namespace LanMountainDesktop.Launcher.Models; + +/// +/// 更新频道 +/// +public enum UpdateChannel +{ + /// + /// 正式版 - 只检查 prerelease=false 的版本 + /// + Stable, + + /// + /// 预览版 - 检查所有版本(包括 prerelease=true) + /// + Preview +} diff --git a/LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs b/LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs new file mode 100644 index 0000000..4e22aac --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs @@ -0,0 +1,13 @@ +namespace LanMountainDesktop.Launcher.Models; + +/// +/// 更新检查结果 +/// +public sealed class UpdateCheckResult +{ + public bool HasUpdate { get; init; } + public string? LatestVersion { get; init; } + public string? CurrentVersion { get; init; } + public ReleaseInfo? Release { get; init; } + public string? ErrorMessage { get; init; } +} diff --git a/LanMountainDesktop.Launcher/Models/UpdateModels.cs b/LanMountainDesktop.Launcher/Models/UpdateModels.cs new file mode 100644 index 0000000..a27741b --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/UpdateModels.cs @@ -0,0 +1,55 @@ +namespace LanMountainDesktop.Launcher.Models; + +internal sealed class SignedFileMap +{ + public string? FromVersion { get; set; } + + public string? ToVersion { get; set; } + + public string? Platform { get; set; } + + public string? Arch { get; set; } + + public List Files { get; set; } = []; +} + +internal sealed class UpdateFileEntry +{ + public string Path { get; set; } = string.Empty; + + public string? ArchivePath { get; set; } + + public string Action { get; set; } = "replace"; + + public string? Sha256 { get; set; } +} + +internal sealed class SnapshotMetadata +{ + public string SnapshotId { get; set; } = string.Empty; + + public string SourceVersion { get; set; } = string.Empty; + + public string? TargetVersion { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public string SourceDirectory { get; set; } = string.Empty; + + public string? TargetDirectory { get; set; } + + public string Status { get; set; } = "pending"; +} + +internal sealed class UpdateApplyResult +{ + public bool Success { get; init; } + + public string Message { get; init; } = string.Empty; + + public string? FromVersion { get; init; } + + public string? ToVersion { get; init; } + + public string? RolledBackTo { get; init; } +} diff --git a/LanMountainDesktop.Launcher/NativeMethods.txt b/LanMountainDesktop.Launcher/NativeMethods.txt new file mode 100644 index 0000000..3428e7b --- /dev/null +++ b/LanMountainDesktop.Launcher/NativeMethods.txt @@ -0,0 +1 @@ +MessageBox diff --git a/LanMountainDesktop.Launcher/Program.cs b/LanMountainDesktop.Launcher/Program.cs new file mode 100644 index 0000000..c60982b --- /dev/null +++ b/LanMountainDesktop.Launcher/Program.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using Avalonia; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Services; + +#if WINDOWS +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; +#endif + +namespace LanMountainDesktop.Launcher; + +internal static class Program +{ + [STAThread] + private static async Task Main(string[] args) + { + var commandContext = CommandContext.FromArgs(args); + + // 处理遗留插件安装命令 + if (commandContext.IsLegacyPluginInstall) + { + var installer = new PluginInstallerService(); + return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false); + } + + // 处理其他 CLI 命令 (update, plugin, rollback 等) + if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase)) + { + return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false); + } + + // 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序 + LauncherRuntimeContext.Current = commandContext; + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + return Environment.ExitCode; + } + + private static int LaunchMainApplication(string[] args) + { + // 获取可执行文件名 + string executableName = OperatingSystem.IsWindows() + ? "LanMountainDesktop.exe" + : "LanMountainDesktop"; + + // 获取安装根目录 + var rootDir = Path.GetFullPath(Path.GetDirectoryName(Environment.ProcessPath) ?? ""); + + // 查找最佳版本 + var installation = FindBestVersion(rootDir, executableName); + + if (installation == null) + { + ShowError("找不到有效的 LanMountainDesktop 版本,可能是安装已损坏。\n请访问 https://github.com/ClassIsland/LanMountainDesktop 重新下载并安装。"); + return 1; + } + + var exePath = Path.Combine(installation, executableName); + + // Linux/macOS: 自动添加可执行权限 + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + try + { + var chmod = Process.Start(new ProcessStartInfo + { + FileName = "chmod", + Arguments = $"+x \"{exePath}\"", + CreateNoWindow = true + }); + chmod?.WaitForExit(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"无法设置可执行权限: {ex.Message}"); + } + } + + // 清理待删除的旧版本 + CleanupDestroyedVersions(rootDir); + + // 启动主程序 + var startInfo = new ProcessStartInfo + { + FileName = exePath, + WorkingDirectory = rootDir, + UseShellExecute = false + }; + + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + // 传递包根目录环境变量 + startInfo.EnvironmentVariables["LanMountainDesktop_PackageRoot"] = rootDir; + + try + { + Process.Start(startInfo); + return 0; + } + catch (Exception ex) + { + ShowError($"启动主程序失败: {ex.Message}"); + return 1; + } + } + + private static string? FindBestVersion(string rootDir, string executableName) + { + return Directory.GetDirectories(rootDir) + .Where(x => + { + var dirName = Path.GetFileName(x); + return dirName.StartsWith("app-") && + !File.Exists(Path.Combine(x, ".destroy")) && + !File.Exists(Path.Combine(x, ".partial")) && + File.Exists(Path.Combine(x, executableName)); + }) + .OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1) // .current 优先 + .ThenByDescending(x => ParseVersion(Path.GetFileName(x))) // 版本号降序 + .FirstOrDefault(); + } + + private static Version ParseVersion(string dirName) + { + // 从 "app-1.0.0" 格式解析版本号 + var parts = dirName.Split('-'); + if (parts.Length >= 2 && Version.TryParse(parts[1], out var version)) + { + return version; + } + return new Version(0, 0, 0); + } + + private static void CleanupDestroyedVersions(string rootDir) + { + try + { + var destroyedDirs = Directory.GetDirectories(rootDir) + .Where(x => File.Exists(Path.Combine(x, ".destroy"))); + + foreach (var dir in destroyedDirs) + { + try + { + Directory.Delete(dir, recursive: true); + } + catch + { + // 忽略删除失败(可能文件被占用),下次启动再试 + } + } + } + catch + { + // 忽略清理失败 + } + } + + private static void ShowError(string message) + { +#if WINDOWS + try + { + PInvoke.MessageBox( + HWND.Null, + message, + "LanMountainDesktop Launcher", + MESSAGEBOX_STYLE.MB_ICONERROR | MESSAGEBOX_STYLE.MB_OK + ); + } + catch + { + Console.Error.WriteLine(message); + } +#else + Console.Error.WriteLine(message); +#endif + } + + private static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} diff --git a/LanMountainDesktop.Launcher/Properties/launchSettings.json b/LanMountainDesktop.Launcher/Properties/launchSettings.json new file mode 100644 index 0000000..7a8f3ab --- /dev/null +++ b/LanMountainDesktop.Launcher/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Launcher (Launch Mode)": { + "commandName": "Project", + "commandLineArgs": "launch", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, + "Launcher (Update Check)": { + "commandName": "Project", + "commandLineArgs": "update check", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, + "Launcher (Plugin Install)": { + "commandName": "Project", + "commandLineArgs": "plugin install ", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/Commands.cs b/LanMountainDesktop.Launcher/Services/Commands.cs new file mode 100644 index 0000000..e94ad82 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/Commands.cs @@ -0,0 +1,174 @@ +using System.Text; +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Services; + +internal static class Commands +{ + public static async Task RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer) + { + var resultPath = context.GetOption("result"); + LauncherResult result; + try + { + var source = context.GetOption("source") ?? string.Empty; + var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty; + result = installer.InstallPackage(source, pluginsDir); + } + catch (Exception ex) + { + result = new LauncherResult + { + Success = false, + Stage = "plugin.install", + Code = "failed", + Message = ex.Message, + ErrorMessage = ex.Message + }; + } + + await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false); + return result.Success ? 0 : 1; + } + + public static async Task RunCliCommandAsync(CommandContext context) + { + var appRoot = ResolveAppRoot(context); + var deploymentLocator = new DeploymentLocator(appRoot); + var updateEngine = new UpdateEngineService(deploymentLocator); + var pluginInstaller = new PluginInstallerService(); + var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller); + + LauncherResult result; + try + { + result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false); + } + catch (Exception ex) + { + result = new LauncherResult + { + Success = false, + Stage = "command", + Code = "exception", + Message = ex.Message, + ErrorMessage = ex.Message + }; + } + + await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false); + return result.Success ? 0 : 1; + } + + private static async Task ExecuteCoreAsync( + CommandContext context, + UpdateEngineService updateEngine, + PluginInstallerService pluginInstaller, + PluginUpgradeQueueService pluginUpgrades) + { + switch (context.Command.ToLowerInvariant()) + { + case "update": + return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false); + case "plugin": + return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades); + default: + return new LauncherResult + { + Success = false, + Stage = "command", + Code = "unsupported_command", + Message = $"Unsupported command '{context.Command}'." + }; + } + } + + private static async Task ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine) + { + return context.SubCommand.ToLowerInvariant() switch + { + "check" => updateEngine.CheckPendingUpdate(), + "apply" => updateEngine.ApplyPendingUpdate(), + "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), + _ => new LauncherResult + { + Success = false, + Stage = "update", + Code = "unsupported_subcommand", + Message = $"Unsupported update sub-command '{context.SubCommand}'." + } + }; + } + + private static LauncherResult ExecutePluginCommand( + CommandContext context, + PluginInstallerService pluginInstaller, + PluginUpgradeQueueService pluginUpgrades) + { + switch (context.SubCommand.ToLowerInvariant()) + { + case "install": + { + var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source."); + var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir."); + return pluginInstaller.InstallPackage(source, pluginsDir); + } + case "update": + { + var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir."); + return pluginUpgrades.ApplyPendingUpgrades(pluginsDir); + } + default: + return new LauncherResult + { + Success = false, + Stage = "plugin", + Code = "unsupported_subcommand", + Message = $"Unsupported plugin sub-command '{context.SubCommand}'." + }; + } + } + + public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result) + { + if (string.IsNullOrWhiteSpace(resultPath)) + { + return; + } + + var fullPath = Path.GetFullPath(resultPath); + var dir = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(dir)) + { + Directory.CreateDirectory(dir); + } + + var json = JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = true + }); + await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false); + } + + public static string ResolveAppRoot(CommandContext context) + { + var configured = context.GetOption("app-root"); + if (!string.IsNullOrWhiteSpace(configured)) + { + return Path.GetFullPath(configured); + } + + var baseDir = AppContext.BaseDirectory; + var parent = Path.GetFullPath(Path.Combine(baseDir, "..")); + var parentHost = OperatingSystem.IsWindows() + ? Path.Combine(parent, "LanMountainDesktop.exe") + : Path.Combine(parent, "LanMountainDesktop"); + return File.Exists(parentHost) ? parent : baseDir; + } +} diff --git a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs new file mode 100644 index 0000000..df3e489 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs @@ -0,0 +1,160 @@ +using System.Globalization; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class DeploymentLocator +{ + private readonly string _appRoot; + + public DeploymentLocator(string appRoot) + { + _appRoot = appRoot; + } + + public string GetAppRoot() => _appRoot; + + public string? FindCurrentDeploymentDirectory() + { + var candidates = Directory.Exists(_appRoot) + ? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly) + : []; + + // 过滤掉无效的部署目录 + var validCandidates = candidates + .Where(path => + !File.Exists(Path.Combine(path, ".destroy")) && // 排除待删除 + !File.Exists(Path.Combine(path, ".partial"))) // 排除未完成 + .ToList(); + + // 优先选择带 .current 标记的版本 + var withMarkers = validCandidates + .Where(path => File.Exists(Path.Combine(path, ".current"))) + .Select(path => new + { + Path = path, + Version = ParseVersionFromDirectory(path) + }) + .OrderByDescending(item => item.Version) + .ToList(); + + if (withMarkers.Count > 0) + { + return withMarkers[0].Path; + } + + // 如果没有 .current 标记,选择最新版本 + var byVersion = validCandidates + .Select(path => new + { + Path = path, + Version = ParseVersionFromDirectory(path) + }) + .OrderByDescending(item => item.Version) + .ToList(); + + return byVersion.Count > 0 ? byVersion[0].Path : null; + } + + public string? ResolveHostExecutablePath() + { + var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; + var currentDeployment = FindCurrentDeploymentDirectory(); + if (!string.IsNullOrWhiteSpace(currentDeployment)) + { + var inDeployment = Path.Combine(currentDeployment, executable); + if (File.Exists(inDeployment)) + { + return inDeployment; + } + } + + var inRoot = Path.Combine(_appRoot, executable); + if (File.Exists(inRoot)) + { + return inRoot; + } + + var parent = Path.GetFullPath(Path.Combine(_appRoot, "..")); + var inParent = Path.Combine(parent, executable); + return File.Exists(inParent) ? inParent : null; + } + + public string GetCurrentVersion() + { + var deployment = FindCurrentDeploymentDirectory(); + if (string.IsNullOrWhiteSpace(deployment)) + { + return "0.0.0"; + } + + return ParseVersionTextFromDirectory(deployment) ?? "0.0.0"; + } + + public string BuildNextDeploymentDirectory(string targetVersion) + { + var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim(); + var index = 0; + while (true) + { + var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}"); + if (!Directory.Exists(candidate)) + { + return candidate; + } + + index++; + } + } + + public void CleanupDestroyedDeployments() + { + try + { + var candidates = Directory.Exists(_appRoot) + ? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly) + : []; + + var destroyedDirs = candidates + .Where(path => File.Exists(Path.Combine(path, ".destroy"))); + + foreach (var dir in destroyedDirs) + { + try + { + Directory.Delete(dir, recursive: true); + } + catch + { + // 忽略删除失败(可能文件被占用),下次启动再试 + } + } + } + catch + { + // 忽略清理失败 + } + } + + public static Version ParseVersionFromDirectory(string path) + { + var text = ParseVersionTextFromDirectory(path); + return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0); + } + + private static string? ParseVersionTextFromDirectory(string path) + { + var fileName = Path.GetFileName(path); + if (string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + var segments = fileName.Split('-'); + if (segments.Length < 2) + { + return null; + } + + return segments[1]; + } +} diff --git a/LanMountainDesktop.Launcher/Services/IOobeStep.cs b/LanMountainDesktop.Launcher/Services/IOobeStep.cs new file mode 100644 index 0000000..8fc0944 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/IOobeStep.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.Launcher.Services; + +internal interface IOobeStep +{ + Task RunAsync(CancellationToken cancellationToken); +} diff --git a/LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs b/LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs new file mode 100644 index 0000000..b98a531 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/ISplashStageReporter.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.Launcher.Services; + +internal interface ISplashStageReporter +{ + void Report(string stage, string message); +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs new file mode 100644 index 0000000..ef1075f --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -0,0 +1,204 @@ +using System.Diagnostics; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Views; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class LauncherFlowCoordinator +{ + private readonly CommandContext _context; + private readonly DeploymentLocator _deploymentLocator; + private readonly OobeStateService _oobeStateService; + private readonly UpdateEngineService _updateEngine; + private readonly UpdateCheckService _updateCheckService; + private readonly PluginInstallerService _pluginInstallerService; + private readonly ISplashStageReporter _splashStageReporter; + private readonly IReadOnlyList _oobeSteps; + + public LauncherFlowCoordinator( + CommandContext context, + DeploymentLocator deploymentLocator, + OobeStateService oobeStateService, + UpdateEngineService updateEngine, + UpdateCheckService updateCheckService, + PluginInstallerService pluginInstallerService) + { + _context = context; + _deploymentLocator = deploymentLocator; + _oobeStateService = oobeStateService; + _updateEngine = updateEngine; + _updateCheckService = updateCheckService; + _pluginInstallerService = pluginInstallerService; + _splashStageReporter = new NullSplashStageReporter(); + _oobeSteps = [new WelcomeOobeStep(_oobeStateService)]; + } + + public async Task RunAsync() + { + try + { + // 清理待删除的旧版本 + _deploymentLocator.CleanupDestroyedDeployments(); + + _splashStageReporter.Report("bootstrap", "bootstrap"); + if (_oobeStateService.IsFirstRun()) + { + foreach (var step in _oobeSteps) + { + await step.RunAsync(CancellationToken.None).ConfigureAwait(false); + } + } + + var splashWindow = await Dispatcher.UIThread.InvokeAsync(() => + { + var window = new SplashWindow(); + window.Show(); + return window; + }); + + try + { + _splashStageReporter.Report("silentUpdate", "update"); + var updateResult = _updateEngine.ApplyPendingUpdate(); + if (!updateResult.Success) + { + return updateResult; + } + + _splashStageReporter.Report("pluginTasks", "plugins"); + var pluginsDir = _context.GetOption("plugins-dir") + ?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins"); + var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir); + if (!queueResult.Success) + { + return queueResult; + } + + _splashStageReporter.Report("launchHost", "launch"); + var hostResult = LaunchHost(); + if (!hostResult.Success) + { + return hostResult; + } + + return new LauncherResult + { + Success = true, + Stage = "exit", + Code = "ok", + Message = "Launcher completed successfully." + }; + } + finally + { + await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close()); + } + } + catch (Exception ex) + { + return new LauncherResult + { + Success = false, + Stage = "launch", + Code = "exception", + Message = ex.Message, + ErrorMessage = ex.Message + }; + } + } + + private LauncherResult LaunchHost() + { + var hostPath = _deploymentLocator.ResolveHostExecutablePath(); + if (string.IsNullOrWhiteSpace(hostPath)) + { + return new LauncherResult + { + Success = false, + Stage = "launchHost", + Code = "host_not_found", + Message = "LanMountainDesktop host executable not found." + }; + } + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + EnsureExecutable(hostPath); + } + + var processStartInfo = new ProcessStartInfo + { + FileName = hostPath, + UseShellExecute = true, + WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot() + }; + + Process.Start(processStartInfo); + return new LauncherResult + { + Success = true, + Stage = "launchHost", + Code = "ok", + Message = "Host launched." + }; + } + + private static void EnsureExecutable(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + try + { + var mode = File.GetUnixFileMode(path); + mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + File.SetUnixFileMode(path, mode); + } + catch + { + } + } + + private sealed class WelcomeOobeStep : IOobeStep + { + private readonly OobeStateService _stateService; + + public WelcomeOobeStep(OobeStateService stateService) + { + _stateService = stateService; + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + var window = await Dispatcher.UIThread.InvokeAsync(() => + { + var oobeWindow = new OobeWindow(); + oobeWindow.Show(); + return oobeWindow; + }); + + try + { + using var _ = cancellationToken.Register(() => window.Close()); + await window.WaitForEnterAsync().ConfigureAwait(false); + _stateService.MarkCompleted(); + } + finally + { + await Dispatcher.UIThread.InvokeAsync(() => window.Close()); + } + } + } + + private sealed class NullSplashStageReporter : ISplashStageReporter + { + public void Report(string stage, string message) + { + _ = stage; + _ = message; + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/OobeStateService.cs b/LanMountainDesktop.Launcher/Services/OobeStateService.cs new file mode 100644 index 0000000..738f997 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/OobeStateService.cs @@ -0,0 +1,29 @@ +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class OobeStateService +{ + private readonly string _markerPath; + + public OobeStateService(string appRoot) + { + var stateDir = Path.Combine(appRoot, ".launcher", "state"); + Directory.CreateDirectory(stateDir); + _markerPath = Path.Combine(stateDir, "first_run_completed"); + } + + public bool IsFirstRun() + { + return !File.Exists(_markerPath); + } + + public void MarkCompleted() + { + var dir = Path.GetDirectoryName(_markerPath); + if (!string.IsNullOrWhiteSpace(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O")); + } +} diff --git a/LanMountainDesktop.PluginsInstallHelper/Program.cs b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs similarity index 52% rename from LanMountainDesktop.PluginsInstallHelper/Program.cs rename to LanMountainDesktop.Launcher/Services/PluginInstallerService.cs index a51ca83..2e4eea2 100644 --- a/LanMountainDesktop.PluginsInstallHelper/Program.cs +++ b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs @@ -1,10 +1,10 @@ using System.IO.Compression; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Launcher.Models; -internal static class Program +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class PluginInstallerService { private static readonly TimeSpan[] RetryDelays = [ @@ -13,103 +13,38 @@ internal static class Program TimeSpan.FromMilliseconds(500) ]; - private static async Task Main(string[] args) + public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory) { - var result = new HelperResult(); - string? resultPath = null; + var fullSourcePath = Path.GetFullPath(sourcePath); + var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory); - try + if (!File.Exists(fullSourcePath)) { - var parsedArgs = ParseArgs(args); - if (!parsedArgs.TryGetValue("source", out var sourcePath) || - !parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) || - !parsedArgs.TryGetValue("result", out resultPath) || - string.IsNullOrWhiteSpace(sourcePath) || - string.IsNullOrWhiteSpace(pluginsDirectory) || - string.IsNullOrWhiteSpace(resultPath)) - { - throw new InvalidOperationException("Required arguments: --source --plugins-dir --result ."); - } - - var fullSourcePath = Path.GetFullPath(sourcePath); - var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory); - resultPath = Path.GetFullPath(resultPath); - - if (!File.Exists(fullSourcePath)) - { - throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath); - } - - var manifest = ReadManifestFromPackage(fullSourcePath); - Directory.CreateDirectory(fullPluginsDirectory); - var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); - var stagingPath = destinationPath + ".incoming"; - DeleteFileWithRetry(stagingPath); - CopyWithRetry(fullSourcePath, stagingPath, overwrite: true); - RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath); - MoveWithOverwriteRetry(stagingPath, destinationPath); - - result = new HelperResult - { - Success = true, - InstalledPackagePath = destinationPath, - ManifestId = manifest.Id, - ManifestName = manifest.Name - }; - } - catch (Exception ex) - { - result = new HelperResult - { - Success = false, - ErrorMessage = ex.Message - }; + throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath); } - if (!string.IsNullOrWhiteSpace(resultPath)) + var manifest = ReadManifestFromPackage(fullSourcePath); + Directory.CreateDirectory(fullPluginsDirectory); + var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); + var stagingPath = destinationPath + ".incoming"; + DeleteFileWithRetry(stagingPath); + CopyWithRetry(fullSourcePath, stagingPath, overwrite: true); + RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath); + MoveWithOverwriteRetry(stagingPath, destinationPath); + + return new LauncherResult { - var resultDirectory = Path.GetDirectoryName(resultPath); - if (!string.IsNullOrWhiteSpace(resultDirectory)) - { - Directory.CreateDirectory(resultDirectory); - } - - await File.WriteAllTextAsync( - resultPath, - JsonSerializer.Serialize(result, new JsonSerializerOptions - { - WriteIndented = true - }), - Encoding.UTF8); - } - - return result.Success ? 0 : 1; + Success = true, + Stage = "plugin.install", + Code = "ok", + Message = "Plugin installed.", + InstalledPackagePath = destinationPath, + ManifestId = manifest.Id, + ManifestName = manifest.Name + }; } - private static Dictionary ParseArgs(string[] args) - { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (var i = 0; i < args.Length; i++) - { - var current = args[i]; - if (!current.StartsWith("--", StringComparison.Ordinal)) - { - continue; - } - - var key = current[2..]; - if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length) - { - continue; - } - - values[key] = args[++i]; - } - - return values; - } - - private static PluginManifest ReadManifestFromPackage(string packagePath) + public PluginManifest ReadManifestFromPackage(string packagePath) { using var archive = ZipFile.OpenRead(packagePath); var entries = archive.Entries @@ -132,7 +67,7 @@ internal static class Program return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}"); } - private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath) + private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath) { var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName)); var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions"); @@ -161,14 +96,13 @@ internal static class Program } catch { - // Ignore unrelated or malformed packages while replacing an install target. } } CleanupPendingDeletions(pendingDeletionDir); } - private static void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir) + private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir) { try { @@ -178,16 +112,7 @@ internal static class Program { var fileName = Path.GetFileName(existingPackagePath); var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending"); - try - { - File.Move(existingPackagePath, pendingPath); - } - catch (IOException moveEx) - { - throw new IOException( - $"Cannot delete or move existing plugin package '{existingPackagePath}'. " + - $"The file may be in use by another process. Error: {moveEx.Message}", moveEx); - } + File.Move(existingPackagePath, pendingPath); } } @@ -206,7 +131,6 @@ internal static class Program } catch { - // Ignore cleanup failures for pending deletions. } } } @@ -235,7 +159,6 @@ internal static class Program private static void Retry(Action action) { Exception? lastException = null; - for (var attempt = 0; attempt <= RetryDelays.Length; attempt++) { try @@ -274,17 +197,4 @@ internal static class Program ? path : path + Path.DirectorySeparatorChar; } - - private sealed class HelperResult - { - public bool Success { get; init; } - - public string? InstalledPackagePath { get; init; } - - public string? ManifestId { get; init; } - - public string? ManifestName { get; init; } - - public string? ErrorMessage { get; init; } - } } diff --git a/LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs b/LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs new file mode 100644 index 0000000..c839b69 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class PluginUpgradeQueueService +{ + private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json"; + + private readonly PluginInstallerService _installerService; + + public PluginUpgradeQueueService(PluginInstallerService installerService) + { + _installerService = installerService; + } + + public LauncherResult ApplyPendingUpgrades(string pluginsDirectory) + { + var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName); + if (!File.Exists(pendingPath)) + { + return new LauncherResult + { + Success = true, + Stage = "plugin.update", + Code = "noop", + Message = "No pending plugin upgrades." + }; + } + + var text = File.ReadAllText(pendingPath); + var pending = JsonSerializer.Deserialize>(text) ?? []; + var failures = new List(); + var succeeded = new List(); + + foreach (var item in pending) + { + if (!item.IsValid()) + { + failures.Add(item.PluginId); + continue; + } + + try + { + _installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory); + succeeded.Add(item); + } + catch + { + failures.Add(item.PluginId); + } + } + + var remaining = pending + .Except(succeeded) + .Where(item => failures.Contains(item.PluginId, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + if (remaining.Count == 0) + { + File.Delete(pendingPath); + } + else + { + File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions + { + WriteIndented = true + })); + } + + return new LauncherResult + { + Success = failures.Count == 0, + Stage = "plugin.update", + Code = failures.Count == 0 ? "ok" : "partial_failed", + Message = failures.Count == 0 + ? $"Applied {succeeded.Count} pending plugin upgrade(s)." + : $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}." + }; + } + + private sealed record PendingUpgrade( + string PluginId, + string SourcePackagePath, + string TargetVersion, + DateTimeOffset CreatedAt) + { + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(PluginId) && + !string.IsNullOrWhiteSpace(SourcePackagePath) && + !string.IsNullOrWhiteSpace(TargetVersion) && + File.Exists(SourcePackagePath); + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs b/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs new file mode 100644 index 0000000..a4302c8 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/UpdateCheckService.cs @@ -0,0 +1,168 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Services; + +/// +/// 更新检查服务 - 基于 GitHub Release API +/// +internal sealed class UpdateCheckService +{ + private const string GitHubApiBase = "https://api.github.com"; + private readonly string _repoOwner; + private readonly string _repoName; + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonOptions; + + public UpdateCheckService(string repoOwner, string repoName) + { + _repoOwner = repoOwner; + _repoName = repoName; + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher"); + _httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json"); + + _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + } + + /// + /// 检查更新 + /// + public async Task CheckForUpdateAsync( + string currentVersion, + UpdateChannel channel, + CancellationToken cancellationToken = default) + { + try + { + var releases = await FetchReleasesAsync(cancellationToken); + + // 根据频道过滤版本 + var filteredReleases = channel == UpdateChannel.Stable + ? releases.Where(r => !r.Prerelease).ToList() + : releases; + + // 找到最新版本 + var latestRelease = filteredReleases + .OrderByDescending(r => ParseVersion(r.TagName)) + .FirstOrDefault(); + + if (latestRelease == null) + { + return new UpdateCheckResult + { + HasUpdate = false, + CurrentVersion = currentVersion, + ErrorMessage = "No releases found" + }; + } + + var latestVersion = ParseVersionString(latestRelease.TagName); + var current = ParseVersion(currentVersion); + var latest = ParseVersion(latestVersion); + + return new UpdateCheckResult + { + HasUpdate = latest > current, + LatestVersion = latestVersion, + CurrentVersion = currentVersion, + Release = latestRelease + }; + } + catch (Exception ex) + { + return new UpdateCheckResult + { + HasUpdate = false, + CurrentVersion = currentVersion, + ErrorMessage = ex.Message + }; + } + } + + /// + /// 获取所有 Release + /// + private async Task> FetchReleasesAsync(CancellationToken cancellationToken) + { + var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases"; + var response = await _httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var releases = JsonSerializer.Deserialize>(json, _jsonOptions); + + return releases?.Select(r => new ReleaseInfo + { + TagName = r.TagName ?? "", + Name = r.Name ?? "", + Prerelease = r.Prerelease, + PublishedAt = r.PublishedAt, + Body = r.Body, + Assets = r.Assets?.Select(a => new ReleaseAsset + { + Name = a.Name ?? "", + BrowserDownloadUrl = a.BrowserDownloadUrl ?? "", + Size = a.Size + }).ToList() ?? [] + }).ToList() ?? []; + } + + /// + /// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0) + /// + private static string ParseVersionString(string tag) + { + return tag.TrimStart('v', 'V'); + } + + /// + /// 解析版本号 + /// + private static Version ParseVersion(string versionString) + { + var cleaned = ParseVersionString(versionString); + return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0); + } + + // GitHub API 响应模型 + private sealed class GitHubRelease + { + [JsonPropertyName("tag_name")] + public string? TagName { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("prerelease")] + public bool Prerelease { get; set; } + + [JsonPropertyName("published_at")] + public DateTime PublishedAt { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + + [JsonPropertyName("assets")] + public List? Assets { get; set; } + } + + private sealed class GitHubAsset + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("browser_download_url")] + public string? BrowserDownloadUrl { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } + } +} diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs new file mode 100644 index 0000000..4995ddd --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -0,0 +1,512 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class UpdateEngineService +{ + private const string LauncherDirectoryName = ".launcher"; + private const string UpdateDirectoryName = "update"; + private const string IncomingDirectoryName = "incoming"; + private const string SnapshotsDirectoryName = "snapshots"; + private const string SignedFileMapName = "files.json"; + private const string SignatureFileName = "files.json.sig"; + private const string ArchiveFileName = "update.zip"; + private const string PublicKeyFileName = "public-key.pem"; + + private readonly DeploymentLocator _deploymentLocator; + private readonly string _appRoot; + private readonly string _launcherRoot; + private readonly string _incomingRoot; + private readonly string _snapshotsRoot; + + public UpdateEngineService(DeploymentLocator deploymentLocator) + { + _deploymentLocator = deploymentLocator; + _appRoot = deploymentLocator.GetAppRoot(); + _launcherRoot = Path.Combine(_appRoot, LauncherDirectoryName); + _incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName); + _snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName); + } + + public LauncherResult CheckPendingUpdate() + { + var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); + var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); + var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); + if (!File.Exists(fileMapPath) || !File.Exists(archivePath)) + { + return new LauncherResult + { + Success = true, + Stage = "update.check", + Code = "noop", + Message = "No pending update." + }; + } + + var fileMapText = File.ReadAllText(fileMapPath); + var fileMap = JsonSerializer.Deserialize(fileMapText); + if (fileMap is null) + { + return Failed("update.check", "invalid_manifest", "files.json is invalid."); + } + + var verified = VerifySignature(fileMapPath, signaturePath); + if (!verified.Success) + { + return Failed("update.check", "signature_failed", verified.Message); + } + + return new LauncherResult + { + Success = true, + Stage = "update.check", + Code = "available", + Message = "Pending update is available.", + CurrentVersion = _deploymentLocator.GetCurrentVersion(), + TargetVersion = fileMap.ToVersion + }; + } + + public async Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken) + { + Directory.CreateDirectory(_incomingRoot); + using var client = new HttpClient + { + Timeout = TimeSpan.FromMinutes(2) + }; + + var manifestPath = Path.Combine(_incomingRoot, SignedFileMapName); + var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); + var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); + + await using (var stream = await client.GetStreamAsync(manifestUrl, cancellationToken).ConfigureAwait(false)) + await using (var output = File.Create(manifestPath)) + { + await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + } + + await using (var stream = await client.GetStreamAsync(signatureUrl, cancellationToken).ConfigureAwait(false)) + await using (var output = File.Create(signaturePath)) + { + await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + } + + await using (var stream = await client.GetStreamAsync(archiveUrl, cancellationToken).ConfigureAwait(false)) + await using (var output = File.Create(archivePath)) + { + await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + } + + return new LauncherResult + { + Success = true, + Stage = "update.download", + Code = "ok", + Message = "Update downloaded." + }; + } + + public LauncherResult ApplyPendingUpdate() + { + Directory.CreateDirectory(_incomingRoot); + Directory.CreateDirectory(_snapshotsRoot); + + var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName); + var signaturePath = Path.Combine(_incomingRoot, SignatureFileName); + var archivePath = Path.Combine(_incomingRoot, ArchiveFileName); + + if (!File.Exists(fileMapPath) || !File.Exists(archivePath)) + { + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "noop", + Message = "No update payload found." + }; + } + + var verifyResult = VerifySignature(fileMapPath, signaturePath); + if (!verifyResult.Success) + { + return Failed("update.apply", "signature_failed", verifyResult.Message); + } + + var fileMapText = File.ReadAllText(fileMapPath); + var fileMap = JsonSerializer.Deserialize(fileMapText); + if (fileMap is null || fileMap.Files.Count == 0) + { + return Failed("update.apply", "invalid_manifest", "No update file entries were found."); + } + + var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); + if (string.IsNullOrWhiteSpace(currentDeployment)) + { + return Failed("update.apply", "no_current_deployment", "Current deployment directory not found."); + } + + var currentVersion = _deploymentLocator.GetCurrentVersion(); + if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) && + !string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) + { + return Failed( + "update.apply", + "version_mismatch", + $"Update requires source version {fileMap.FromVersion} but current is {currentVersion}."); + } + + var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!; + var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion); + var partialMarker = Path.Combine(targetDeployment, ".partial"); + var snapshot = new SnapshotMetadata + { + SnapshotId = Guid.NewGuid().ToString("N"), + SourceVersion = currentVersion, + 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"); + try + { + SaveSnapshot(snapshotPath, snapshot); + + if (Directory.Exists(extractRoot)) + { + Directory.Delete(extractRoot, true); + } + + Directory.CreateDirectory(extractRoot); + ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true); + + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(partialMarker, string.Empty); + + foreach (var file in fileMap.Files) + { + ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot); + } + + foreach (var file in fileMap.Files) + { + if (!NeedsVerification(file)) + { + continue; + } + + var fullPath = Path.Combine(targetDeployment, file.Path); + var actualHash = ComputeSha256Hex(fullPath); + if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"File hash mismatch for '{file.Path}'."); + } + } + + 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 = currentVersion, + 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 = currentVersion, + RolledBackTo = currentVersion + }; + } + finally + { + try + { + if (Directory.Exists(extractRoot)) + { + Directory.Delete(extractRoot, true); + } + } + catch + { + } + } + } + + public LauncherResult RollbackLatest() + { + if (!Directory.Exists(_snapshotsRoot)) + { + return Failed("update.rollback", "no_snapshot", "No snapshot found."); + } + + var snapshotPath = Directory + .EnumerateFiles(_snapshotsRoot, "*.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(File.GetCreationTimeUtc) + .FirstOrDefault(); + if (string.IsNullOrWhiteSpace(snapshotPath)) + { + return Failed("update.rollback", "no_snapshot", "No snapshot found."); + } + + var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath)); + if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory)) + { + return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata."); + } + + var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); + if (string.IsNullOrWhiteSpace(currentDeployment)) + { + return Failed("update.rollback", "no_current_deployment", "Current deployment not found."); + } + + ActivateDeployment(currentDeployment, snapshot.SourceDirectory); + snapshot.Status = "manual_rollback"; + SaveSnapshot(snapshotPath, snapshot); + + return new LauncherResult + { + Success = true, + Stage = "update.rollback", + Code = "ok", + Message = $"Rolled back to {snapshot.SourceVersion}.", + RolledBackTo = snapshot.SourceVersion + }; + } + + public void CleanupDestroyedDeployments() + { + foreach (var dir in Directory.EnumerateDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)) + { + if (!File.Exists(Path.Combine(dir, ".destroy"))) + { + continue; + } + + try + { + Directory.Delete(dir, true); + } + catch + { + } + } + } + + private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot) + { + var normalizedPath = NormalizeRelativePath(file.Path); + if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var targetPath = Path.Combine(targetDeployment, normalizedPath); + EnsurePathWithinRoot(targetPath, targetDeployment); + var targetDir = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(targetDir)) + { + Directory.CreateDirectory(targetDir); + } + + if (string.Equals(file.Action, "reuse", StringComparison.OrdinalIgnoreCase)) + { + var sourcePath = Path.Combine(currentDeployment, normalizedPath); + EnsurePathWithinRoot(sourcePath, currentDeployment); + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment."); + } + + File.Copy(sourcePath, targetPath, overwrite: true); + return; + } + + var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath); + var extractedPath = Path.Combine(extractRoot, archiveRelative); + EnsurePathWithinRoot(extractedPath, extractRoot); + if (!File.Exists(extractedPath)) + { + throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'."); + } + + File.Copy(extractedPath, targetPath, overwrite: true); + } + + private void ActivateDeployment(string fromDeployment, string toDeployment) + { + var toCurrent = Path.Combine(toDeployment, ".current"); + var fromCurrent = Path.Combine(fromDeployment, ".current"); + var fromDestroy = Path.Combine(fromDeployment, ".destroy"); + var toPartial = Path.Combine(toDeployment, ".partial"); + + File.WriteAllText(toCurrent, string.Empty); + if (File.Exists(fromCurrent)) + { + File.Delete(fromCurrent); + } + + File.WriteAllText(fromDestroy, string.Empty); + if (File.Exists(toPartial)) + { + File.Delete(toPartial); + } + } + + private void TryRollbackOnFailure(SnapshotMetadata snapshot) + { + try + { + if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory)) + { + Directory.Delete(snapshot.TargetDirectory, true); + } + + if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy"))) + { + File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy")); + } + + if (!File.Exists(Path.Combine(snapshot.SourceDirectory, ".current"))) + { + File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty); + } + } + catch + { + } + } + + private void CleanupIncomingArtifacts() + { + foreach (var path in new[] + { + Path.Combine(_incomingRoot, SignedFileMapName), + Path.Combine(_incomingRoot, SignatureFileName), + Path.Combine(_incomingRoot, ArchiveFileName) + }) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + } + } + } + + private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath) + { + if (!File.Exists(signaturePath)) + { + return (false, "Missing files.json.sig."); + } + + var publicKeyPath = Path.Combine(_launcherRoot, UpdateDirectoryName, PublicKeyFileName); + if (!File.Exists(publicKeyPath)) + { + return (false, $"Missing public key: {publicKeyPath}"); + } + + var jsonBytes = File.ReadAllBytes(fileMapPath); + var signatureBase64 = File.ReadAllText(signaturePath).Trim(); + if (string.IsNullOrWhiteSpace(signatureBase64)) + { + return (false, "Signature is empty."); + } + + byte[] signature; + try + { + signature = Convert.FromBase64String(signatureBase64); + } + catch (FormatException) + { + return (false, "Signature is not valid base64."); + } + + using var rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(publicKeyPath)); + var isValid = rsa.VerifyData(jsonBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return isValid ? (true, "ok") : (false, "Signature verification failed."); + } + + private static string NormalizeRelativePath(string path) + { + var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + return normalized.TrimStart(Path.DirectorySeparatorChar); + } + + private static void EnsurePathWithinRoot(string targetPath, string rootPath) + { + var fullTarget = Path.GetFullPath(targetPath); + var fullRoot = Path.GetFullPath(rootPath); + if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Path traversal detected: {targetPath}"); + } + } + + private static bool NeedsVerification(UpdateFileEntry file) + { + return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(file.Sha256); + } + + private static string ComputeSha256Hex(string filePath) + { + using var stream = File.OpenRead(filePath); + var hash = SHA256.HashData(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static void SaveSnapshot(string path, SnapshotMetadata snapshot) + { + File.WriteAllText(path, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions + { + WriteIndented = true + })); + } + + private static LauncherResult Failed(string stage, string code, string message) + { + return new LauncherResult + { + Success = false, + Stage = stage, + Code = code, + Message = message, + ErrorMessage = message + }; + } +} diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml new file mode 100644 index 0000000..8cf08bf --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml @@ -0,0 +1,22 @@ + + + +