diff --git a/.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md b/.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md new file mode 100644 index 0000000..ee3afbe --- /dev/null +++ b/.cursor/plans/launcher_单项目解耦_302f1ec6.plan.md @@ -0,0 +1,432 @@ +--- +name: Launcher 单项目解耦 +overview: 在保持单一 LanMountainDesktop.Launcher 项目、单一 exe、零部署风险的前提下,按职责域增量重构:目录分层、RunAsync→Pipeline+Phase、UpdateEngine→策略类、App→纯 Avalonia+LauncherOrchestrator;执行过程中由 Agent 自主 Git 提交,每域可编译可测。 +todos: + - id: phase-a-diagnostics + content: Phase A:Startup 诊断 + HostStartupMonitor 独立类 + AOT 启动检测竞态修复 + 测试 + status: completed + - id: phase-b-directory + content: Phase B1:职责域目录迁移(Deployment/Update/Startup/Oobe/Plugins/Infrastructure),零逻辑变更,提交 + status: completed + - id: phase-b-pipeline + content: Phase B2:RunAsync→LaunchPipeline+ILaunchPhase,引入 LauncherOrchestrator,删除 LauncherFlowCoordinator,提交 + status: completed + - id: phase-b-app-slim + content: Phase B3:App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator,提交 + status: completed + - id: phase-c-di + content: Phase C:LauncherServiceRegistration + 轻量 MS DI,统一 CLI/GUI 装配,提交 + status: completed + - id: phase-d-update-split + content: Phase D:UpdateEngineService→门面+策略类(Verifier/Activator/Rollback 等),提交 + status: completed + - id: phase-e-guardrails + content: Phase E:LauncherArchitectureTests + 文档 + AOT 回归,提交 + status: completed +isProject: false +--- + +# Launcher 单项目内部解耦改造计划(执行版) + +## 0. 硬性约束 + + +| 约束 | 说明 | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **单项目** | 仅 `[LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj](LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj)`,不新建 Launcher.* 独立程序集 | +| **单 exe** | 仍只发布 `LanMountainDesktop.Launcher.exe`(AOT 单文件) | +| **零部署风险** | 不改变安装包目录结构、不引入新进程、不改变 Public IPC / Coordinator IPC 拓扑与契约 | +| **增量重构** | 一个职责域一域推进,每步 `dotnet build` + 相关 `dotnet test` 通过后再进下一步 | +| **单进程性能** | 模块间仅 in-process 接口调用,不为解耦新增 IPC | +| **未来可拆** | 各域暴露 `I`* 接口,将来若需多进程可直接复用契约 | +| **Git 自主提交** | Agent 在每个职责域完成且验证通过后 **自动 commit**,无需用户手动提交(见 §8) | + + +外部共享库 `[LanMountainDesktop.PluginPackaging](LanMountainDesktop.PluginPackaging/)` 保留(Host + Launcher CLI 共用),不属于 Launcher 拆分。 + +--- + +## 1. 验收标准(必须全部满足) + +### 1.1 零部署风险 + +- Inno Setup / CI 产物仍只有:`LanMountainDesktop.Launcher.exe` + `app-{version}/` + `.launcher/` +- Host 调用 Launcher 的 CLI 参数、`launch-source`、`apply-update` 路径不变 +- Public IPC routes(`lanmountain.launcher.startup-progress`、`loading-state`)与 Coordinator pipe 不变 +- VeloPack / 更新 apply 状态机(`.current/.partial/.destroy`)行为不变 + +### 1.2 增量可验证 + +- 每个 Phase 结束:编译绿 + 该域新增/既有测试绿 +- 允许「纯移动文件」的 PR 单独提交,行为 diff 为零 + +### 1.3 测试友好 + +- `Startup/`、`Update/`、`Deployment/` 内类型 **无 Avalonia 依赖**,可独立单元测试 +- 每个 `ILaunchPhase`、每个 Update 策略类各有对应测试类 +- 保留并扩展现有 `[LauncherStartupTimeoutPolicyTests](LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs)`、`[LauncherMultiInstancePolicyTests](LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs)` + +### 1.4 启动性能 + +- Pipeline 阶段为同步/异步方法调用链,不引入额外进程或网络 +- DI 容器仅在进程入口构建一次;Stage/Phase 实例可复用 Singleton + +### 1.5 代码结构目标 + + +| 对象 | 当前(实测) | 目标 | +| ----------------------------------- | -------------------------------------------- | --------------------------------------------------- | +| `LauncherFlowCoordinator` 全 partial | ~1880 行(859+568+279+…) | **删除**;逻辑迁入 Pipeline + Phases | +| `RunAsync()` 等价逻辑 | 跨 partial ~800+ 行 while/阶段混杂 | **≤80 行** 编排入口,细节在各 Phase | +| `UpdateEngineService` | ~1622 行 | 门面 **≤200 行** + 6 个策略类各 **≤300 行** | +| `App.axaml.cs` | ~258 行(已部分瘦身) | **≤120 行**:纯 Avalonia + 一行委托 `LauncherOrchestrator` | +| `LauncherOrchestrator` | 不存在(逻辑在 Coordinator + CompositionRoot 546 行) | **≤250 行**:GUI 入口编排 | +| `LauncherCompositionRoot` | ~546 行 | **≤150 行**:仅 DI 构建 + 入口分发 | + + +--- + +## 2. 目标架构 + +### 2.1 核心类型关系 + +```mermaid +flowchart TB + Program --> EntryRouter + App --> LauncherOrchestrator + EntryRouter --> LauncherOrchestrator + LauncherOrchestrator --> LaunchPipeline + LaunchPipeline --> Phase1[CleanupPhase] + LaunchPipeline --> Phase2[OobeGatePhase] + LaunchPipeline --> Phase3[ApplyUpdatePhase] + LaunchPipeline --> Phase4[LaunchHostPhase] + LaunchPipeline --> Phase5[MonitorStartupPhase] + Phase3 --> IUpdateEngine + Phase4 --> IDeploymentLocator + Phase5 --> IHostStartupMonitor + LauncherCompositionRoot --> ServiceProvider + ServiceProvider --> LaunchPipeline +``` + + + +**命名约定:** + +- `**LauncherOrchestrator`**:GUI 生命周期内的唯一编排入口(取代 `LauncherFlowCoordinator` 对外角色) +- `**LaunchPipeline**`:按序执行 `ILaunchPhase` 列表 +- `**ILaunchPhase**`:原 `ILaunchPipelineStage`;每个 Phase 对应原 `RunAsync` 中一个职责段 + +### 2.2 职责域目录(单项目内) + +``` +LanMountainDesktop.Launcher/ +├── Program.cs # CLI / GUI 路由 +├── App.axaml.cs # 纯 Avalonia(≤120 行) +├── Shell/ +│ ├── LauncherOrchestrator.cs # GUI 编排入口 +│ ├── LauncherCompositionRoot.cs # DI + Entry 分发 +│ ├── LaunchPipeline.cs +│ ├── Phases/ # ILaunchPhase 实现 +│ │ ├── CleanupDeploymentsPhase.cs +│ │ ├── OobeGatePhase.cs +│ │ ├── ApplyPendingUpdatePhase.cs +│ │ ├── LaunchHostPhase.cs +│ │ └── MonitorStartupPhase.cs +│ └── EntryHandlers/ # apply-update / air-app-broker / attach +├── Deployment/ +├── Update/ +│ ├── IUpdateEngine.cs +│ ├── UpdateEngineFacade.cs # 原 UpdateEngineService 门面 +│ └── Strategies/ +│ ├── PendingUpdateDetector.cs +│ ├── UpdatePackageVerifier.cs +│ ├── DeploymentActivator.cs +│ ├── UpdateSnapshotStore.cs +│ ├── RollbackStrategy.cs +│ └── IncomingArtifactsCleaner.cs +├── Startup/ +├── Oobe/ +├── Ipc/ +├── AirApp/ +├── Plugins/ +├── Infrastructure/ +├── Models/ +└── Views/ +``` + +### 2.3 模块依赖规则 + +- `Deployment/`、`Update/`、`Startup/`:**禁止** `using Avalonia` +- `Views/`:**禁止** 引用具体 `UpdateEngineFacade` / `DeploymentLocator`(仅接口或 Orchestrator) +- 跨域:**仅通过 `I`* 接口**;Orchestrator/Pipeline 负责装配 + +### 2.4 与 Host 边界(不变) + + +| 能力 | Owner | +| -------------------------- | ------------------------------ | +| OOBE / Splash / 多实例 / 启动检测 | Launcher `Startup/` + `Shell/` | +| 更新 apply / rollback | Launcher `Update/` | +| 插件市场 / pending | Host + PluginPackaging | +| 更新 download | Host → spawn Launcher apply | + + +--- + +## 3. 三大核心拆分(用户指定) + +### 3.1 拆分 `LauncherFlowCoordinator`:`RunAsync` → Pipeline + Phase + +**现状:** 逻辑分散在 4 个 partial,等效一个 1800+ 行 God Class;`RunAsync` 内含清理、OOBE、更新、启动、IPC 监听、超时 while-loop、多实例分支。 + +**目标 API(单项目 `Shell/` 内):** + +```csharp +internal interface ILaunchPhase +{ + string PhaseId { get; } + /// null = 继续下一阶段;非 null = 管道终止并返回结果 + Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken); +} + +internal sealed class LaunchPipeline +{ + public LaunchPipeline(IEnumerable phases) { ... } + public Task RunAsync(LaunchContext context, CancellationToken ct); +} +``` + +**Phase 映射(与原 RunAsync 步骤一一对应):** + + +| Phase | 原 RunAsync 段 | 产出 | +| ------------------------- | --------------------------------------- | ----------------------------- | +| `CleanupDeploymentsPhase` | `CleanupOldDeployments` | 无 UI | +| `ExistingHostProbePhase` | 多实例 / Public IPC 探测 | 可短路成功 | +| `ApplyPendingUpdatePhase` | `_updateEngine.ApplyPendingUpdateAsync` | 失败仍继续 | +| `OobeGatePhase` | migration + OOBE steps | UI via `ILauncherUiPresenter` | +| `LaunchHostPhase` | `LaunchHostWithIpcAsync` | Process + plan | +| `MonitorStartupPhase` | while-loop + IPC + timeout | 调用 `IHostStartupMonitor` | + + +`**LauncherOrchestrator` 职责:** + +- 接收 `SplashWindow`、构建 `LaunchContext`(含 reporter、attempt registry、coordinator server) +- 调用 `LaunchPipeline.RunAsync` +- 管理 Splash/Error 窗口生命周期(委托 `ILauncherUiPresenter`) +- **不含** 更新/部署/IPC 细节 + +**删除清单:** `LauncherFlowCoordinator.cs` 及全部 partial 文件。 + +--- + +### 3.2 拆分 `UpdateEngineService` → 门面 + 策略类 + +**现状:** ~1622 行单文件,混合检测、验签、解压、激活、快照、回滚、清理。 + +**目标结构:** + +``` +Update/ +├── IUpdateEngine.cs # 对外契约(未来多进程可原样抽出) +├── UpdateEngineFacade.cs # 门面,编排策略,≤200 行 +└── Strategies/ + ├── IUpdateStrategy.cs # 可选:各策略统一接口 + ├── PendingUpdateDetector.cs # CheckPendingUpdate + ├── UpdatePackageVerifier.cs # manifest + RSA 签名 + ├── UpdatePackageExtractor.cs # 解压 / 增量复用 + ├── DeploymentActivator.cs # .current / .partial / .destroy + ├── UpdateSnapshotStore.cs # snapshots 读写 + ├── RollbackStrategy.cs # rollback CLI/GUI + └── IncomingArtifactsCleaner.cs # CleanupIncomingArtifacts +``` + +**门面方法映射:** + + +| 原 `UpdateEngineService` 公开方法 | 委托策略 | +| ---------------------------- | ------------------------------------------------------ | +| `CheckPendingUpdate()` | `PendingUpdateDetector` | +| `ApplyPendingUpdateAsync()` | Detector → Verifier → Extractor → Activator → Snapshot | +| `RollbackLatest()` | `RollbackStrategy` | +| `CleanupIncomingArtifacts()` | `IncomingArtifactsCleaner` | +| `DownloadAsync()`(若有) | 保持或拆 `UpdateDownloader` | + + +**测试:** 每个 Strategy 独立 mock `IDeploymentLocator` / 文件系统,不启 Avalonia。 + +--- + +### 3.3 精简 `App.axaml.cs` → 纯 Avalonia + `LauncherOrchestrator` + +**现状:** ~258 行,仍含 apply-update、air-app-broker、preview、coordinator attach 等分支。 + +**目标结构:** + +```csharp +// App.axaml.cs 目标形态(概念) +public override void OnFrameworkInitializationCompleted() +{ + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var context = LauncherRuntimeContext.Current; + var mode = LauncherEntryModeResolver.Resolve(context); + _ = LauncherOrchestrator.RunAsync(desktop, context, mode); + } + base.OnFrameworkInitializationCompleted(); +} +``` + +**从 App 迁出的逻辑 → `Shell/EntryHandlers/`:** + + +| 现 App 分支 | 新 Handler | +| ----------------- | -------------------------------------- | +| `launch` + splash | `GuiLaunchEntryHandler` → Orchestrator | +| `apply-update` | `ApplyUpdateEntryHandler` | +| `air-app-broker` | `AirAppBrokerEntryHandler` | +| debug preview | `PreviewEntryHandler` | + + +**验收:** `App.axaml.cs` ≤120 行;不含 `new UpdateEngineService` / `new DeploymentLocator` / while-loop。 + +--- + +## 4. 分阶段执行顺序与 Git 提交点 + +```mermaid +flowchart LR + A[Phase A Startup] --> B1[Phase B1 目录迁移] + B1 --> B2[Phase B2 Pipeline+Orchestrator] + B2 --> B3[Phase B3 App 精简] + B3 --> C[Phase C DI] + B1 --> D[Phase D Update 策略拆分] + C --> E[Phase E 守卫+文档+AOT回归] + D --> E +``` + + + +### Phase A:Startup 子系统 + AOT 生产 bug(优先) + +- 抽出 `Startup/HostStartupMonitor.cs`(从 partial 独立) +- 修复 IPC 连接退避、成功判定统一走 `StartupSuccessTracker` +- Host 侧 `DesktopVisible` 上报对齐(仅日志/时序,不改 IPC 契约) +- 测试 + `**git commit**`: `fix(launcher): extract HostStartupMonitor and harden startup detection` + +### Phase B1:目录迁移(零逻辑变更) + +- 物理移动文件到 `Deployment/`、`Update/`、`Startup/` 等,更新 namespace +- `dotnet build` + test +- `**git commit**`: `refactor(launcher): reorganize into responsibility folders` + +### Phase B2:Pipeline + Phase + LauncherOrchestrator + +- 实现 `ILaunchPhase`、`LaunchPipeline`、`LauncherOrchestrator` +- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试) +- 删除 `LauncherFlowCoordinator*` +- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline` + +### Phase B3:App.axaml.cs 精简 + +- EntryHandlers 提取;App 仅 Avalonia + Orchestrator 委托 +- `**git commit**`: `refactor(launcher): slim App.axaml.cs to Avalonia shell only` + +### Phase C:轻量 DI + +- `LauncherServiceRegistration.cs` + `Microsoft.Extensions.DependencyInjection` +- Program / CliHost / CompositionRoot 统一 `ServiceProvider` +- `**git commit**`: `refactor(launcher): add composition-root DI wiring` + +### Phase D:UpdateEngine 策略拆分(可与 B2 并行,依赖 B1) + +- 策略类提取 + `UpdateEngineFacade` +- 删除原巨型 `UpdateEngineService.cs` +- 每策略测试 +- `**git commit**`: `refactor(launcher): split UpdateEngine into strategy classes` + +### Phase E:守卫 + 文档 + AOT 回归 + +- `LauncherArchitectureTests`(命名空间依赖规则) +- 更新 `[docs/LAUNCHER.md](docs/LAUNCHER.md)`、`[.trae/specs/launcher-shell-hardening/spec.md](.trae/specs/launcher-shell-hardening/spec.md)` +- AOT publish 本地 smoke:launch / apply-update / 多实例 / 启动检测 +- `**git commit**`: `docs(launcher): document module boundaries and add architecture tests` + +--- + +## 5. Phase / Service 测试矩阵 + + +| 组件 | 测试文件 | 覆盖点 | +| ----------------------- | ---------------------------- | --------------------------------- | +| `StartupSuccessTracker` | `StartupSuccessTrackerTests` | Foreground/Tray/Background policy | +| `HostStartupMonitor` | `HostStartupMonitorTests` | 超时、IPC 延迟、ShellStatus 轮询 | +| `LaunchPipeline` | `LaunchPipelineTests` | Phase 短路、失败传播 | +| 各 `ILaunchPhase` | `*PhaseTests` | 单阶段 mock | +| `PendingUpdateDetector` | `PendingUpdateDetectorTests` | 无 pending / corrupt | +| `DeploymentActivator` | `DeploymentActivatorTests` | 标记文件状态机 | +| `RollbackStrategy` | `RollbackStrategyTests` | 快照回退 | +| 命名空间规则 | `LauncherArchitectureTests` | 无 Avalonia 泄漏 | + + +--- + +## 6. 明确不做 + +- 不新建 csproj(Launcher.Deployment 等) +- 不新建 exe / Windows Service +- 不改变 Public IPC / Coordinator IPC 协议 +- 不把插件市场安装迁回 Launcher +- 不为模块间通信引入新 IPC(仅保留现有 Host↔Launcher 契约) + +--- + +## 7. 风险与缓解 + + +| 风险 | 缓解 | +| --------------- | ------------------------------------------------------------------ | +| 大规模移动 merge 冲突 | B1 独立 commit,零逻辑变更 | +| Pipeline 迁移行为回归 | 先写 Phase 级测试再迁代码;保留 `LMD_LAUNCHER_LEGACY_COORDINATOR=1` 开关一个版本(可选) | +| AOT + DI | 显式注册,禁止反射扫描;`PublishAot` CI 步骤验证 | +| Update 拆分遗漏路径 | CLI `update *` 与 GUI apply-update 同一 `IUpdateEngine` 门面 | + + +--- + +## 8. Git 工作流(Agent 自主提交) + +**原则:** 每个 Phase 验证通过后立即提交;不累积巨型 uncommitted diff。 + +**Commit 前检查(每个 commit 必做):** + +```bash +dotnet build LanMountainDesktop.slnx -c Debug +dotnet test LanMountainDesktop.slnx -c Debug --filter "FullyQualifiedName~Launcher" +``` + +**Commit message 风格(与仓库一致):** + +``` +refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline + +Pipeline + Phase pattern; LauncherOrchestrator becomes GUI entry. +No deployment or IPC contract changes. +``` + +**禁止:** `git push --force`、修改 git config、跳过 hooks(除非 hook 失败需修复后新 commit)。 + +**建议分支:** `refactor/launcher-internal-modularization`(单 long-lived 分支,按 Phase 连续 commit;或每 Phase 一个 PR 由用户决定 merge 时机)。 + +--- + +## 9. 整体完成定义(Definition of Done) + +- 无 `LauncherFlowCoordinator` 源文件 +- `App.axaml.cs` ≤120 行,仅 Avalonia + Orchestrator 委托 +- `UpdateEngineService` 巨型文件已替换为 Facade + Strategies +- 职责域目录就位,架构测试通过 +- 全量 Launcher 相关测试 + AOT publish smoke 通过 +- 安装包结构与 IPC 拓扑与重构前一致 +- 每个 Phase 有对应 Git commit,工作区 clean + diff --git a/LanMountainDesktop.Launcher/Infrastructure/Commands.cs b/LanMountainDesktop.Launcher/Infrastructure/Commands.cs index f3ae780..b4f149a 100644 --- a/LanMountainDesktop.Launcher/Infrastructure/Commands.cs +++ b/LanMountainDesktop.Launcher/Infrastructure/Commands.cs @@ -36,7 +36,7 @@ internal static class Commands { var appRoot = ResolveAppRoot(context); var deploymentLocator = new DeploymentLocator(appRoot); - var updateEngine = new UpdateEngineFacade(deploymentLocator); + var updateEngine = UpdateEngineFactory.Create(deploymentLocator); var pluginInstaller = new PluginInstallerService(); var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller); @@ -63,7 +63,7 @@ internal static class Commands private static async Task ExecuteCoreAsync( CommandContext context, - UpdateEngineFacade updateEngine, + IUpdateEngine updateEngine, PluginInstallerService pluginInstaller, PluginUpgradeQueueService pluginUpgrades) { @@ -84,7 +84,7 @@ internal static class Commands } } - private static async Task ExecuteUpdateAsync(CommandContext context, UpdateEngineFacade updateEngine) + private static async Task ExecuteUpdateAsync(CommandContext context, IUpdateEngine updateEngine) { return context.SubCommand.ToLowerInvariant() switch { @@ -102,7 +102,7 @@ internal static class Commands }; } - private static async Task DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineFacade updateEngine) + private static async Task DownloadUpdatePayloadAsync(CommandContext context, IUpdateEngine updateEngine) { return await updateEngine.DownloadAsync( context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."), diff --git a/LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs b/LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs new file mode 100644 index 0000000..6e926a7 --- /dev/null +++ b/LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs @@ -0,0 +1,78 @@ +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Resources; +using LanMountainDesktop.Launcher.Views; + +namespace LanMountainDesktop.Launcher.Shell; + +internal static class ApplyUpdateGuiFlow +{ + public static async Task RunAsync( + IClassicDesktopStyleApplicationLifetime desktop, + CommandContext context, + UpdateWindow window) + { + var appRoot = Commands.ResolveAppRoot(context); + var deploymentLocator = new DeploymentLocator(appRoot); + var updateEngine = UpdateEngineFactory.Create(deploymentLocator); + var pluginInstaller = new PluginInstallerService(); + var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller); + + var success = true; + string? errorMessage = null; + + try + { + await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10)); + var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); + if (!updateResult.Success) + { + success = false; + errorMessage = updateResult.Message; + } + + if (success) + { + await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60)); + var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins"); + var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir); + if (!queueResult.Success && queueResult.Code != "noop") + { + Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}"); + } + } + + if (success) + { + await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90)); + deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); + } + } + catch (Exception ex) + { + success = false; + errorMessage = ex.Message; + Logger.Error("Apply-update flow failed.", ex); + } + + await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage)); + await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false); + + await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult + { + Success = success, + Stage = "apply-update", + Code = success ? "ok" : "failed", + Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"), + Details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["command"] = context.Command, + ["launchSource"] = context.LaunchSource + } + }).ConfigureAwait(false); + + Environment.ExitCode = success ? 0 : 1; + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); + } +} diff --git a/LanMountainDesktop.Launcher/Infrastructure/DeferredSplashStageReporter.cs b/LanMountainDesktop.Launcher/Shell/DeferredSplashStageReporter.cs similarity index 94% rename from LanMountainDesktop.Launcher/Infrastructure/DeferredSplashStageReporter.cs rename to LanMountainDesktop.Launcher/Shell/DeferredSplashStageReporter.cs index 4d2db12..67a5051 100644 --- a/LanMountainDesktop.Launcher/Infrastructure/DeferredSplashStageReporter.cs +++ b/LanMountainDesktop.Launcher/Shell/DeferredSplashStageReporter.cs @@ -1,7 +1,7 @@ using Avalonia.Threading; using LanMountainDesktop.Launcher.Views; -namespace LanMountainDesktop.Launcher.Infrastructure; +namespace LanMountainDesktop.Launcher.Shell; internal sealed class DeferredSplashStageReporter : ISplashStageReporter { diff --git a/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs b/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs index d9f5e23..04e5ded 100644 --- a/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs +++ b/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs @@ -37,7 +37,7 @@ internal static class ApplyUpdateEntryHandler IClassicDesktopStyleApplicationLifetime desktop, CommandContext context, UpdateWindow window) => - LauncherCompositionRoot.RunApplyUpdateWithWindowAsync(desktop, context, window); + ApplyUpdateGuiFlow.RunAsync(desktop, context, window); } internal static class AirAppBrokerEntryHandler diff --git a/LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs b/LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs similarity index 94% rename from LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs rename to LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs index bdf719b..78a22c4 100644 --- a/LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs +++ b/LanMountainDesktop.Launcher/Shell/LaunchUiPresenter.cs @@ -4,10 +4,20 @@ using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.IPC.Abstractions.Services; -namespace LanMountainDesktop.Launcher.Startup; +namespace LanMountainDesktop.Launcher.Shell; internal static class LaunchUiPresenter { + public static async Task HideSplashAsync(SplashWindow splashWindow) + { + await Dispatcher.UIThread.InvokeAsync(splashWindow.Hide); + } + + public static async Task ShowSplashAsync(SplashWindow splashWindow) + { + await Dispatcher.UIThread.InvokeAsync(splashWindow.Show); + } + public static async Task CloseWindowsAsync(SplashWindow splashWindow, LoadingDetailsWindow? loadingDetailsWindow) { try diff --git a/LanMountainDesktop.Launcher/Infrastructure/LauncherBackgroundService.cs b/LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs similarity index 99% rename from LanMountainDesktop.Launcher/Infrastructure/LauncherBackgroundService.cs rename to LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs index d6bd978..0bb53b1 100644 --- a/LanMountainDesktop.Launcher/Infrastructure/LauncherBackgroundService.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherBackgroundService.cs @@ -1,6 +1,6 @@ using Avalonia.Media.Imaging; -namespace LanMountainDesktop.Launcher.Infrastructure; +namespace LanMountainDesktop.Launcher.Shell; /// /// 启动器背景图片服务 diff --git a/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs b/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs index 74e2fec..8964fa8 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs @@ -1,17 +1,10 @@ -using System.Diagnostics; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Threading; -using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Resources; using LanMountainDesktop.Launcher.Views; -using LanMountainDesktop.Shared.Contracts.Launcher; -using LanMountainDesktop.Shared.IPC; -using LanMountainDesktop.Shared.IPC.Abstractions.Services; namespace LanMountainDesktop.Launcher.Shell; /// -/// Launcher GUI 入口装配:创建编排器并驱动启动流程。 +/// Launcher GUI composition root. It only wires services and dispatches to entry coordinators. /// internal static class LauncherCompositionRoot { @@ -19,593 +12,15 @@ internal static class LauncherCompositionRoot CommandContext context, string appRoot, StartupAttemptRegistry startupAttemptRegistry, - LauncherCoordinatorIpcServer coordinatorServer) => - LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer); + LauncherCoordinatorIpcServer coordinatorServer) + { + _ = appRoot; + return LauncherServiceRegistration.CreateOrchestrator(context, startupAttemptRegistry, coordinatorServer); + } - public static async Task RunOrchestratorWithSplashAsync( + public static Task RunOrchestratorWithSplashAsync( IClassicDesktopStyleApplicationLifetime desktop, CommandContext context, - SplashWindow splashWindow) - { - LauncherResult result; - SplashWindow? currentSplashWindow = splashWindow; - var appRoot = Commands.ResolveAppRoot(context); - var dataLocationResolver = new DataLocationResolver(appRoot); - var startupAttemptRegistry = new StartupAttemptRegistry(); - var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); - var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context); - if (!startupAttemptRegistry.TryReserveCoordinator( - context.LaunchSource, - successPolicy, - coordinatorPipeName, - out var reservedAttempt, - out var activeCoordinatorAttempt)) - { - result = await AttachToExistingCoordinatorAsync( - context, - currentSplashWindow, - activeCoordinatorAttempt).ConfigureAwait(false); - - Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'."); - await WriteLauncherResultAsync(context, result).ConfigureAwait(false); - - Environment.ExitCode = result.Success ? 0 : 1; - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); - return; - } - - using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( - new LauncherAirAppLifecycleService( - new AirAppProcessStarter( - new AirAppHostLocator(), - () => appRoot, - () => null, - () => dataLocationResolver.ResolveDataRoot()))); - airAppIpcHost.Start(); - - using var coordinatorServer = new LauncherCoordinatorIpcServer( - coordinatorPipeName, - BuildCoordinatorStatusFromAttempt(reservedAttempt), - HandleCoordinatorRequestAsync, - startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat); - coordinatorServer.Start(); - - while (true) - { - try - { - Logger.Info( - $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + - $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + - $"ResultPath='{context.GetOption("result") ?? ""}'."); - - var orchestrator = CreateOrchestrator( - context, - appRoot, - startupAttemptRegistry, - coordinatorServer); - - result = await orchestrator.RunAsync(currentSplashWindow).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error("Coordinator threw an unhandled exception.", ex); - result = new LauncherResult - { - Success = false, - Stage = "launch", - Code = "exception", - Message = $"Launcher failed: {ex.Message}", - ErrorMessage = ex.ToString() - }; - } - - if (result.Success || - result.Code == "host_not_found" || - (!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) && - !string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase))) - { - break; - } - - var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false); - if (failureAction == ErrorWindowResult.Exit) - { - break; - } - - if (failureAction == ErrorWindowResult.ActivateExisting && - await TryActivateExistingInstanceAsync().ConfigureAwait(false)) - { - result = new LauncherResult - { - Success = true, - Stage = "launch", - Code = "activation_requested", - Message = "Launcher activated the existing desktop instance.", - Details = result.Details - }; - break; - } - - currentSplashWindow = CreateSplashWindow(); - currentSplashWindow.Show(); - } - - Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'."); - await WriteLauncherResultAsync(context, result).ConfigureAwait(false); - - Environment.ExitCode = result.Success ? 0 : 1; - if (result.Success) - { - var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); - await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); - } - - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); - } - - public static async Task RunApplyUpdateWithWindowAsync( - IClassicDesktopStyleApplicationLifetime desktop, - CommandContext context, - UpdateWindow window) - { - var appRoot = Commands.ResolveAppRoot(context); - var deploymentLocator = new DeploymentLocator(appRoot); - var updateEngine = new UpdateEngineFacade(deploymentLocator); - var pluginInstaller = new PluginInstallerService(); - var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller); - - var success = true; - string? errorMessage = null; - - try - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10)); - var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); - if (!updateResult.Success) - { - success = false; - errorMessage = updateResult.Message; - } - - if (success) - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60)); - var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins"); - var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir); - if (!queueResult.Success && queueResult.Code != "noop") - { - Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}"); - } - } - - if (success) - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90)); - deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); - } - } - catch (Exception ex) - { - success = false; - errorMessage = ex.Message; - Logger.Error("Apply-update flow failed.", ex); - } - - await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage)); - await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false); - - await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult - { - Success = success, - Stage = "apply-update", - Code = success ? "ok" : "failed", - Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"), - Details = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["command"] = context.Command, - ["launchSource"] = context.LaunchSource - } - }).ConfigureAwait(false); - - Environment.ExitCode = success ? 0 : 1; - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); - } - - private static SplashWindow CreateSplashWindow() - { - var window = new SplashWindow(); - TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current); - return window; - } - - private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context) - { - try - { - var appRoot = Commands.ResolveAppRoot(context); - var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo(); - window.SetVersionInfo(versionInfo.Version, versionInfo.Codename); - } - catch (Exception ex) - { - Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}"); - } - } - - private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid) - { - if (result.Details.TryGetValue("hostPid", out var hostPidText) && - int.TryParse(hostPidText, out var hostPid)) - { - return hostPid; - } - - if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) && - int.TryParse(existingHostPidText, out var existingHostPid)) - { - return existingHostPid; - } - - return fallbackHostPid; - } - - private static async Task WaitForManagedProcessesToExitAsync( - int hostPid, - LauncherAirAppLifecycleService airAppLifecycleService) - { - Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}."); - while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps()) - { - await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - } - - Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains."); - } - - private static async Task AttachToExistingCoordinatorAsync( - CommandContext context, - SplashWindow? splashWindow, - StartupAttemptRecord? activeCoordinatorAttempt) - { - var reporter = splashWindow as ISplashStageReporter; - reporter?.Report("activation", Strings.Preview_ActivationConnecting); - - if (activeCoordinatorAttempt is not null && - !string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName)) - { - var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase) - ? LauncherCoordinatorCommands.Attach - : LauncherCoordinatorCommands.ActivateDesktop; - var request = new LauncherCoordinatorRequest - { - Command = command, - LaunchSource = context.LaunchSource, - SuccessPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context) - }; - - var response = await new LauncherCoordinatorIpcClient() - .SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2)) - .ConfigureAwait(false); - - if (response is not null) - { - reporter?.Report("activation", response.Message); - await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); - var success = response.Accepted || - IsRecoverableActivationFailure(response.ActivationResult, response.Status); - return new LauncherResult - { - Success = success, - Stage = "launch", - Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code, - Message = success && !response.Accepted - ? "Attached to the active Launcher coordinator; desktop startup is still in progress." - : response.Message, - Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult) - }; - } - } - - var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - if (activation is not null) - { - reporter?.Report("activation", activation.Message); - await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); - var success = activation.Accepted || IsRecoverableActivationFailure(activation, null); - return new LauncherResult - { - Success = success, - Stage = "launch", - Code = activation.Accepted - ? "existing_host_activated" - : success - ? "existing_host_startup_pending" - : "existing_host_activation_failed", - Message = success && !activation.Accepted - ? "Existing desktop process is still starting; Launcher attached without starting another process." - : activation.Message, - Details = BuildCoordinatorResultDetails(null, activation) - }; - } - - await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); - return new LauncherResult - { - Success = false, - Stage = "launch", - Code = "launcher_coordinator_unavailable", - Message = "Another Launcher is coordinating startup, but it did not respond in time.", - Details = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty, - ["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty, - ["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty, - ["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty - } - }; - } - - private static async Task HandleCoordinatorRequestAsync( - LauncherCoordinatorRequest request, - LauncherCoordinatorStatus status) - { - if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase)) - { - var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - if (activation is not null) - { - if (!activation.Accepted && IsRecoverableActivationFailure(activation, status)) - { - return new LauncherCoordinatorResponse - { - Accepted = true, - Code = "attached_to_launcher_coordinator", - Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.", - Status = status, - ActivationResult = activation - }; - } - - return new LauncherCoordinatorResponse - { - Accepted = activation.Accepted, - Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed", - Message = activation.Message, - Status = status, - ActivationResult = activation - }; - } - - return new LauncherCoordinatorResponse - { - Accepted = true, - Code = "attached_to_launcher_coordinator", - Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.", - Status = status - }; - } - - return new LauncherCoordinatorResponse - { - Accepted = true, - Code = "attached_to_launcher_coordinator", - Message = "Attached to the active Launcher coordinator.", - Status = status - }; - } - - private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt) - { - return new LauncherCoordinatorStatus - { - AttemptId = attempt.AttemptId, - CoordinatorPid = Environment.ProcessId, - HostPid = attempt.HostPid, - HostProcessAlive = TryGetLiveProcess(attempt.HostPid), - LaunchSource = attempt.LaunchSource, - SuccessPolicy = attempt.SuccessPolicy, - LastObservedStage = attempt.LastObservedStage, - LastObservedMessage = attempt.LastObservedMessage, - PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected, - State = attempt.State.ToString(), - SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting, - Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed, - Succeeded = attempt.State == StartupAttemptState.Succeeded, - UpdatedAtUtc = attempt.UpdatedAtUtc - }; - } - - private static bool IsRecoverableActivationFailure( - PublicShellActivationResult? activation, - LauncherCoordinatorStatus? status) - { - if (activation is { Accepted: true }) - { - return false; - } - - if (status is { Completed: false, HostProcessAlive: true }) - { - return true; - } - - var shellStatus = activation?.Status; - if (shellStatus is null || !shellStatus.PublicIpcReady) - { - return false; - } - - return !shellStatus.MainWindowOpened || - !shellStatus.DesktopVisible || - string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) || - string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase); - } - - private static Dictionary BuildCoordinatorResultDetails( - LauncherCoordinatorStatus? status, - PublicShellActivationResult? activation) - { - return new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty, - ["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty, - ["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty, - ["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty, - ["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(), - ["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty, - ["startupState"] = status?.State ?? string.Empty, - ["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty, - ["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty, - ["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty, - ["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty - }; - } - - private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow) - { - if (splashWindow is null) - { - return; - } - - try - { - await splashWindow.DismissAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}"); - } - } - - private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result) - { - var resultPath = context.GetOption("result"); - if (string.IsNullOrWhiteSpace(resultPath)) - { - return; - } - - try - { - await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false); - Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'."); - } - catch (Exception ex) - { - Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex); - } - } - - private static async Task ShowFailureWindowAsync(LauncherResult result) - { - ErrorWindow? errorWindow = null; - var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) && - bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) && - hostProcessAliveValue; - var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) && - int.TryParse(hostPidText, out var parsedPid) - ? parsedPid - : (int?)null; - - await Dispatcher.UIThread.InvokeAsync(() => - { - try - { - errorWindow = new ErrorWindow(); - if (hostProcessAlive) - { - errorWindow.ConfigureForRunningHostFailure(hostPid); - } - else - { - errorWindow.ConfigureForGenericFailure(allowRetry: true); - } - - errorWindow.SetErrorMessage( - $"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}"); - errorWindow.Show(); - } - catch (Exception ex) - { - Logger.Error("Failed to show launcher failure window.", ex); - } - }); - - if (errorWindow is null) - { - return ErrorWindowResult.Exit; - } - - try - { - return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error("Failure window closed unexpectedly.", ex); - return ErrorWindowResult.Exit; - } - } - - private static async Task TryActivateExistingInstanceAsync() - { - var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); - return activation?.Accepted == true; - } - - private static async Task TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout) - { - try - { - using var ipcClient = new LanMountainDesktopIpcClient(); - var connectTask = ipcClient.ConnectAsync(); - var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); - if (completedTask != connectTask) - { - return null; - } - - await connectTask.ConfigureAwait(false); - if (!ipcClient.IsConnected) - { - return null; - } - - var shellProxy = ipcClient.CreateProxy(); - var activationTask = shellProxy.ActivateMainWindowWithStatusAsync(); - completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false); - if (completedTask != activationTask) - { - return null; - } - - return await activationTask.ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}"); - return null; - } - } - - private static bool TryGetLiveProcess(int processId) - { - if (processId <= 0) - { - return false; - } - - try - { - using var process = Process.GetProcessById(processId); - return !process.HasExited; - } - catch - { - return false; - } - } + SplashWindow splashWindow) => + LauncherGuiCoordinator.RunAsync(desktop, context, splashWindow); } diff --git a/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs b/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs new file mode 100644 index 0000000..214892c --- /dev/null +++ b/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs @@ -0,0 +1,533 @@ +using System.Diagnostics; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Resources; +using LanMountainDesktop.Launcher.Views; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; + +namespace LanMountainDesktop.Launcher.Shell; + +internal static class LauncherGuiCoordinator +{ + public static async Task RunAsync( + IClassicDesktopStyleApplicationLifetime desktop, + CommandContext context, + SplashWindow splashWindow) + { + LauncherResult result; + SplashWindow? currentSplashWindow = splashWindow; + var appRoot = Commands.ResolveAppRoot(context); + var dataLocationResolver = new DataLocationResolver(appRoot); + var startupAttemptRegistry = new StartupAttemptRegistry(); + var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); + var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context); + if (!startupAttemptRegistry.TryReserveCoordinator( + context.LaunchSource, + successPolicy, + coordinatorPipeName, + out var reservedAttempt, + out var activeCoordinatorAttempt)) + { + result = await AttachToExistingCoordinatorAsync( + context, + currentSplashWindow, + activeCoordinatorAttempt).ConfigureAwait(false); + + Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'."); + await WriteLauncherResultAsync(context, result).ConfigureAwait(false); + + Environment.ExitCode = result.Success ? 0 : 1; + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); + return; + } + + using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( + new LauncherAirAppLifecycleService( + new AirAppProcessStarter( + new AirAppHostLocator(), + () => appRoot, + () => null, + () => dataLocationResolver.ResolveDataRoot()))); + airAppIpcHost.Start(); + + using var coordinatorServer = new LauncherCoordinatorIpcServer( + coordinatorPipeName, + BuildCoordinatorStatusFromAttempt(reservedAttempt), + HandleCoordinatorRequestAsync, + startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat); + coordinatorServer.Start(); + + while (true) + { + try + { + Logger.Info( + $"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " + + $"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " + + $"ResultPath='{context.GetOption("result") ?? ""}'."); + + var orchestrator = LauncherCompositionRoot.CreateOrchestrator( + context, + appRoot, + startupAttemptRegistry, + coordinatorServer); + + result = await orchestrator.RunAsync(currentSplashWindow).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Coordinator threw an unhandled exception.", ex); + result = new LauncherResult + { + Success = false, + Stage = "launch", + Code = "exception", + Message = $"Launcher failed: {ex.Message}", + ErrorMessage = ex.ToString() + }; + } + + if (result.Success || + result.Code == "host_not_found" || + (!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) && + !string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase))) + { + break; + } + + var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false); + if (failureAction == ErrorWindowResult.Exit) + { + break; + } + + if (failureAction == ErrorWindowResult.ActivateExisting && + await TryActivateExistingInstanceAsync().ConfigureAwait(false)) + { + result = new LauncherResult + { + Success = true, + Stage = "launch", + Code = "activation_requested", + Message = "Launcher activated the existing desktop instance.", + Details = result.Details + }; + break; + } + + currentSplashWindow = CreateSplashWindow(); + currentSplashWindow.Show(); + } + + Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'."); + await WriteLauncherResultAsync(context, result).ConfigureAwait(false); + + Environment.ExitCode = result.Success ? 0 : 1; + if (result.Success) + { + var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); + await WaitForManagedProcessesToExitAsync(hostPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); + } + + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); + } + + private static SplashWindow CreateSplashWindow() + { + var window = new SplashWindow(); + TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current); + return window; + } + + private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context) + { + try + { + var appRoot = Commands.ResolveAppRoot(context); + var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo(); + window.SetVersionInfo(versionInfo.Version, versionInfo.Codename); + } + catch (Exception ex) + { + Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}"); + } + } + + private static int ResolveManagedHostPid(LauncherResult result, int fallbackHostPid) + { + if (result.Details.TryGetValue("hostPid", out var hostPidText) && + int.TryParse(hostPidText, out var hostPid)) + { + return hostPid; + } + + if (result.Details.TryGetValue("existingHostPid", out var existingHostPidText) && + int.TryParse(existingHostPidText, out var existingHostPid)) + { + return existingHostPid; + } + + return fallbackHostPid; + } + + private static async Task WaitForManagedProcessesToExitAsync( + int hostPid, + LauncherAirAppLifecycleService airAppLifecycleService) + { + Logger.Info($"Launcher entering managed background lifetime. HostPid={hostPid}."); + while (TryGetLiveProcess(hostPid) || airAppLifecycleService.HasLiveAirApps()) + { + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + + Logger.Info("Launcher managed background lifetime completed; no host or Air APP process remains."); + } + + private static async Task AttachToExistingCoordinatorAsync( + CommandContext context, + SplashWindow? splashWindow, + StartupAttemptRecord? activeCoordinatorAttempt) + { + var reporter = splashWindow as ISplashStageReporter; + reporter?.Report("activation", Strings.Preview_ActivationConnecting); + + if (activeCoordinatorAttempt is not null && + !string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName)) + { + var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase) + ? LauncherCoordinatorCommands.Attach + : LauncherCoordinatorCommands.ActivateDesktop; + var request = new LauncherCoordinatorRequest + { + Command = command, + LaunchSource = context.LaunchSource, + SuccessPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context) + }; + + var response = await new LauncherCoordinatorIpcClient() + .SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2)) + .ConfigureAwait(false); + + if (response is not null) + { + reporter?.Report("activation", response.Message); + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + var success = response.Accepted || + IsRecoverableActivationFailure(response.ActivationResult, response.Status); + return new LauncherResult + { + Success = success, + Stage = "launch", + Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code, + Message = success && !response.Accepted + ? "Attached to the active Launcher coordinator; desktop startup is still in progress." + : response.Message, + Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult) + }; + } + } + + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (activation is not null) + { + reporter?.Report("activation", activation.Message); + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + var success = activation.Accepted || IsRecoverableActivationFailure(activation, null); + return new LauncherResult + { + Success = success, + Stage = "launch", + Code = activation.Accepted + ? "existing_host_activated" + : success + ? "existing_host_startup_pending" + : "existing_host_activation_failed", + Message = success && !activation.Accepted + ? "Existing desktop process is still starting; Launcher attached without starting another process." + : activation.Message, + Details = BuildCoordinatorResultDetails(null, activation) + }; + } + + await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false); + return new LauncherResult + { + Success = false, + Stage = "launch", + Code = "launcher_coordinator_unavailable", + Message = "Another Launcher is coordinating startup, but it did not respond in time.", + Details = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty, + ["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty, + ["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty, + ["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty + } + }; + } + + private static async Task HandleCoordinatorRequestAsync( + LauncherCoordinatorRequest request, + LauncherCoordinatorStatus status) + { + if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase)) + { + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + if (activation is not null) + { + if (!activation.Accepted && IsRecoverableActivationFailure(activation, status)) + { + return new LauncherCoordinatorResponse + { + Accepted = true, + Code = "attached_to_launcher_coordinator", + Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.", + Status = status, + ActivationResult = activation + }; + } + + return new LauncherCoordinatorResponse + { + Accepted = activation.Accepted, + Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed", + Message = activation.Message, + Status = status, + ActivationResult = activation + }; + } + + return new LauncherCoordinatorResponse + { + Accepted = true, + Code = "attached_to_launcher_coordinator", + Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.", + Status = status + }; + } + + return new LauncherCoordinatorResponse + { + Accepted = true, + Code = "attached_to_launcher_coordinator", + Message = "Attached to the active Launcher coordinator.", + Status = status + }; + } + + private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt) + { + return new LauncherCoordinatorStatus + { + AttemptId = attempt.AttemptId, + CoordinatorPid = Environment.ProcessId, + HostPid = attempt.HostPid, + HostProcessAlive = TryGetLiveProcess(attempt.HostPid), + LaunchSource = attempt.LaunchSource, + SuccessPolicy = attempt.SuccessPolicy, + LastObservedStage = attempt.LastObservedStage, + LastObservedMessage = attempt.LastObservedMessage, + PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected, + State = attempt.State.ToString(), + SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting, + Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed, + Succeeded = attempt.State == StartupAttemptState.Succeeded, + UpdatedAtUtc = attempt.UpdatedAtUtc + }; + } + + private static bool IsRecoverableActivationFailure( + PublicShellActivationResult? activation, + LauncherCoordinatorStatus? status) + { + if (activation is { Accepted: true }) + { + return false; + } + + if (status is { Completed: false, HostProcessAlive: true }) + { + return true; + } + + var shellStatus = activation?.Status; + if (shellStatus is null || !shellStatus.PublicIpcReady) + { + return false; + } + + return !shellStatus.MainWindowOpened || + !shellStatus.DesktopVisible || + string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) || + string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase); + } + + private static Dictionary BuildCoordinatorResultDetails( + LauncherCoordinatorStatus? status, + PublicShellActivationResult? activation) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty, + ["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty, + ["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty, + ["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty, + ["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(), + ["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty, + ["startupState"] = status?.State ?? string.Empty, + ["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty, + ["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty, + ["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty, + ["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty + }; + } + + private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow) + { + if (splashWindow is null) + { + return; + } + + try + { + await splashWindow.DismissAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}"); + } + } + + private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result) + { + var resultPath = context.GetOption("result"); + if (string.IsNullOrWhiteSpace(resultPath)) + { + return; + } + + try + { + await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false); + Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'."); + } + catch (Exception ex) + { + Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex); + } + } + + private static async Task ShowFailureWindowAsync(LauncherResult result) + { + ErrorWindow? errorWindow = null; + var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) && + bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) && + hostProcessAliveValue; + var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) && + int.TryParse(hostPidText, out var parsedPid) + ? parsedPid + : (int?)null; + + await Dispatcher.UIThread.InvokeAsync(() => + { + try + { + errorWindow = new ErrorWindow(); + if (hostProcessAlive) + { + errorWindow.ConfigureForRunningHostFailure(hostPid); + } + else + { + errorWindow.ConfigureForGenericFailure(allowRetry: true); + } + + errorWindow.SetErrorMessage( + $"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}"); + errorWindow.Show(); + } + catch (Exception ex) + { + Logger.Error("Failed to show launcher failure window.", ex); + } + }); + + if (errorWindow is null) + { + return ErrorWindowResult.Exit; + } + + try + { + return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error("Failure window closed unexpectedly.", ex); + return ErrorWindowResult.Exit; + } + } + + private static async Task TryActivateExistingInstanceAsync() + { + var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + return activation?.Accepted == true; + } + + private static async Task TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout) + { + try + { + using var ipcClient = new LanMountainDesktopIpcClient(); + var connectTask = ipcClient.ConnectAsync(); + var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false); + if (completedTask != connectTask) + { + return null; + } + + await connectTask.ConfigureAwait(false); + if (!ipcClient.IsConnected) + { + return null; + } + + var shellProxy = ipcClient.CreateProxy(); + var activationTask = shellProxy.ActivateMainWindowWithStatusAsync(); + completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false); + if (completedTask != activationTask) + { + return null; + } + + return await activationTask.ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}"); + return null; + } + } + + private static bool TryGetLiveProcess(int processId) + { + if (processId <= 0) + { + return false; + } + + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch + { + return false; + } + } +} diff --git a/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs b/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs index abe8218..1df34d7 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs @@ -22,7 +22,7 @@ internal static class LauncherServiceRegistration services.AddSingleton(new DeploymentLocator(appRoot)); services.AddSingleton(sp => new OobeStateService(appRoot)); services.AddSingleton(sp => new DataLocationResolver(appRoot)); - services.AddSingleton(sp => new UpdateEngineFacade(sp.GetRequiredService())); + services.AddSingleton(sp => UpdateEngineFactory.Create(sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LanMountainDesktop.Launcher/Infrastructure/ThemeService.cs b/LanMountainDesktop.Launcher/Shell/ThemeService.cs similarity index 96% rename from LanMountainDesktop.Launcher/Infrastructure/ThemeService.cs rename to LanMountainDesktop.Launcher/Shell/ThemeService.cs index fa35383..f7802b0 100644 --- a/LanMountainDesktop.Launcher/Infrastructure/ThemeService.cs +++ b/LanMountainDesktop.Launcher/Shell/ThemeService.cs @@ -2,7 +2,7 @@ using Avalonia; using Avalonia.Styling; using FluentAvalonia.Styling; -namespace LanMountainDesktop.Launcher.Infrastructure; +namespace LanMountainDesktop.Launcher.Shell; /// /// 主题服务,管理启动器的主题设置 diff --git a/LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs b/LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs index 5851963..955e38d 100644 --- a/LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs +++ b/LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs @@ -1,3 +1,4 @@ +using LanMountainDesktop.Launcher.Shell; using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.IPC; diff --git a/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs b/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs index 19a5c27..da11603 100644 --- a/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs +++ b/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Shell; using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Shared.Contracts.Launcher; diff --git a/LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs index 5b7a53e..8fb6c36 100644 --- a/LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs +++ b/LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs @@ -1,3 +1,4 @@ +using LanMountainDesktop.Launcher.Shell; using LanMountainDesktop.Shared.Contracts.Launcher; namespace LanMountainDesktop.Launcher.Startup; diff --git a/LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs index 7c264ef..ce3e15c 100644 --- a/LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs +++ b/LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs @@ -1,4 +1,5 @@ using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Shell; using LanMountainDesktop.Shared.Contracts.Launcher; namespace LanMountainDesktop.Launcher.Startup; diff --git a/LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs index 9d97811..52c5a26 100644 --- a/LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs +++ b/LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs @@ -1,3 +1,5 @@ +using LanMountainDesktop.Launcher.Shell; + namespace LanMountainDesktop.Launcher.Startup; internal sealed class MonitorStartupPhase : ILaunchPhase diff --git a/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs index eff7803..2730d5c 100644 --- a/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs +++ b/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs @@ -1,4 +1,4 @@ -using Avalonia.Threading; +using LanMountainDesktop.Launcher.Shell; namespace LanMountainDesktop.Launcher.Startup; @@ -10,13 +10,13 @@ internal sealed class OobeGatePhase : ILaunchPhase { if (context.OobeDecision.ShouldShowOobe) { - await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Hide()); + await LaunchUiPresenter.HideSplashAsync(context.SplashWindow).ConfigureAwait(false); foreach (var step in context.OobeSteps) { await step.RunAsync(cancellationToken).ConfigureAwait(false); } - await Dispatcher.UIThread.InvokeAsync(() => context.SplashWindow.Show()); + await LaunchUiPresenter.ShowSplashAsync(context.SplashWindow).ConfigureAwait(false); } return new LaunchPhaseResult(LaunchPhaseStatus.Continue); diff --git a/LanMountainDesktop.Launcher/Update/DeploymentActivator.cs b/LanMountainDesktop.Launcher/Update/DeploymentActivator.cs new file mode 100644 index 0000000..4efccf1 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/DeploymentActivator.cs @@ -0,0 +1,73 @@ +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator) +{ + public void Activate(string fromDeployment, string toDeployment) + { + var toCurrent = Path.Combine(toDeployment, ".current"); + var fromCurrent = Path.Combine(fromDeployment, ".current"); + var fromDestroy = Path.Combine(fromDeployment, ".destroy"); + var toDestroy = Path.Combine(toDeployment, ".destroy"); + var toPartial = Path.Combine(toDeployment, ".partial"); + + File.WriteAllText(toCurrent, string.Empty); + if (File.Exists(toDestroy)) + { + File.Delete(toDestroy); + } + + if (File.Exists(fromCurrent)) + { + File.Delete(fromCurrent); + } + + File.WriteAllText(fromDestroy, string.Empty); + if (File.Exists(toPartial)) + { + File.Delete(toPartial); + } + } + + public RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot) + { + try + { + if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory)) + { + Directory.Delete(snapshot.TargetDirectory, true); + } + + if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory) || !Directory.Exists(snapshot.SourceDirectory)) + { + return new RollbackAttemptResult(false, "Source deployment is missing."); + } + + var destroyMarker = Path.Combine(snapshot.SourceDirectory, ".destroy"); + if (File.Exists(destroyMarker)) + { + File.Delete(destroyMarker); + } + + var currentMarker = Path.Combine(snapshot.SourceDirectory, ".current"); + if (!File.Exists(currentMarker)) + { + File.WriteAllText(currentMarker, string.Empty); + } + + return new RollbackAttemptResult(true, null); + } + catch (Exception ex) + { + return new RollbackAttemptResult(false, ex.Message); + } + } + + public void RetainDeploymentsForRollback() + { + deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); + } +} + +internal sealed record RollbackAttemptResult(bool Success, string? ErrorMessage); diff --git a/LanMountainDesktop.Launcher/Update/IncomingArtifactsCleaner.cs b/LanMountainDesktop.Launcher/Update/IncomingArtifactsCleaner.cs new file mode 100644 index 0000000..4cb854c --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/IncomingArtifactsCleaner.cs @@ -0,0 +1,51 @@ +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class IncomingArtifactsCleaner(UpdateEnginePaths paths) +{ + public void Cleanup() + { + foreach (var path in new[] + { + paths.FileMapPath, + paths.SignaturePath, + paths.ArchivePath, + paths.PlondsFileMapPath, + paths.PlondsSignaturePath, + paths.PlondsUpdateMetadataPath, + paths.InstallCheckpointPath + }) + { + TryDeleteFile(path); + } + + TryDeleteDirectory(paths.PlondsObjectsRoot); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch + { + } + } +} diff --git a/LanMountainDesktop.Launcher/Update/InstallCheckpointStore.cs b/LanMountainDesktop.Launcher/Update/InstallCheckpointStore.cs new file mode 100644 index 0000000..95a2156 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/InstallCheckpointStore.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class InstallCheckpointStore(UpdateEnginePaths paths) +{ + public InstallCheckpoint? Load() + { + if (!File.Exists(paths.InstallCheckpointPath)) + { + return null; + } + + try + { + var text = File.ReadAllText(paths.InstallCheckpointPath); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint); + } + catch + { + return null; + } + } + + public void Save(InstallCheckpoint checkpoint) + { + File.WriteAllText(paths.InstallCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); + } + + public void Delete() + { + try + { + if (File.Exists(paths.InstallCheckpointPath)) + { + File.Delete(paths.InstallCheckpointPath); + } + } + catch + { + } + } +} diff --git a/LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs b/LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs new file mode 100644 index 0000000..7bf8130 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs @@ -0,0 +1,287 @@ +using System.IO.Compression; +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; +using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class LegacyUpdateApplier( + DeploymentLocator deploymentLocator, + UpdateEnginePaths paths, + UpdateSignatureVerifier signatureVerifier, + IUpdateProgressReporter progressReporter, + UpdateSnapshotStore snapshotStore, + InstallCheckpointStore checkpointStore, + DeploymentActivator deploymentActivator, + IncomingArtifactsCleaner incomingCleaner) +{ + public async Task ApplyAsync() + { + if (!File.Exists(paths.FileMapPath) || !File.Exists(paths.ArchivePath)) + { + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "noop", + Message = "No update payload found." + }; + } + + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0)); + var verifyResult = signatureVerifier.Verify(paths.FileMapPath, paths.SignaturePath, UpdateEnginePaths.SignatureFileName); + if (!verifyResult.Success) + { + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); + return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message); + } + + var fileMapText = await File.ReadAllTextAsync(paths.FileMapPath).ConfigureAwait(false); + var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); + if (fileMap is null || fileMap.Files.Count == 0) + { + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false)); + return UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No update file entries were found."); + } + + var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory(); + var currentVersion = deploymentLocator.GetCurrentVersion(); + if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) && + !string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) + { + return UpdateEngineResults.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 existingCheckpoint = checkpointStore.Load(); + var canResume = existingCheckpoint is not null + && string.Equals(existingCheckpoint.SourceVersion, currentVersion, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(existingCheckpoint.TargetDirectory) + && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); + + if (existingCheckpoint is not null && !canResume) + { + return UpdateEngineResults.Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); + } + + var targetDeployment = canResume + ? existingCheckpoint!.TargetDirectory + : deploymentLocator.BuildNextDeploymentDirectory(targetVersion); + var snapshot = BuildSnapshot(canResume, existingCheckpoint, currentVersion, targetVersion, currentDeployment, targetDeployment); + var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId); + var checkpoint = canResume + ? existingCheckpoint! + : BuildCheckpoint(snapshot, currentVersion, targetVersion, currentDeployment, targetDeployment); + + try + { + snapshotStore.Save(snapshotPath, snapshot); + PrepareExtractRoot(); + ZipFile.ExtractToDirectory(paths.ArchivePath, paths.ExtractRoot, overwriteFiles: true); + + if (!canResume) + { + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count)); + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); + } + + checkpointStore.Save(checkpoint); + ApplyFiles(fileMap, currentDeployment!, targetDeployment, checkpoint); + VerifyFiles(fileMap, targetDeployment, checkpoint); + + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count)); + deploymentActivator.Activate(currentDeployment!, targetDeployment); + + snapshot.Status = "applied"; + snapshotStore.Save(snapshotPath, snapshot); + incomingCleaner.Cleanup(); + deploymentActivator.RetainDeploymentsForRollback(); + + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count)); + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false)); + + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "ok", + Message = $"Updated to {targetVersion}.", + CurrentVersion = currentVersion, + TargetVersion = targetVersion + }; + } + catch (Exception ex) + { + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); + var rollbackResult = deploymentActivator.TryRollbackOnFailure(snapshot); + snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; + snapshotStore.Save(snapshotPath, snapshot); + var errorMessage = rollbackResult.Success + ? ex.Message + : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, errorMessage, rollbackResult.Success)); + return new LauncherResult + { + Success = false, + Stage = "update.apply", + Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", + Message = rollbackResult.Success + ? "Failed to apply update. Rolled back to previous version." + : "Failed to apply update and rollback failed.", + ErrorMessage = errorMessage, + CurrentVersion = currentVersion, + RolledBackTo = rollbackResult.Success ? currentVersion : null + }; + } + finally + { + checkpointStore.Delete(); + TryDeleteExtractRoot(); + } + } + + private void ApplyFiles(SignedFileMap fileMap, string currentDeployment, string targetDeployment, InstallCheckpoint checkpoint) + { + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, checkpoint.AppliedCount, fileMap.Files.Count)); + for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileMap.Files.Count; fileIndex++) + { + var file = fileMap.Files[fileIndex]; + ApplyFileEntry(file, currentDeployment, targetDeployment); + checkpoint.AppliedCount = fileIndex + 1; + checkpointStore.Save(checkpoint); + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (checkpoint.AppliedCount * 30 / fileMap.Files.Count), file.Path, checkpoint.AppliedCount, fileMap.Files.Count)); + } + } + + private void VerifyFiles(SignedFileMap fileMap, string targetDeployment, InstallCheckpoint checkpoint) + { + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, checkpoint.VerifiedCount, fileMap.Files.Count)); + for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileMap.Files.Count; verifyIndex++) + { + var file = fileMap.Files[verifyIndex]; + if (NeedsVerification(file)) + { + var fullPath = Path.Combine(targetDeployment, file.Path); + var actualHash = UpdateHash.ComputeSha256Hex(fullPath); + if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"File hash mismatch for '{file.Path}'."); + } + } + + checkpoint.VerifiedCount = verifyIndex + 1; + checkpointStore.Save(checkpoint); + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileMap.Files.Count), file.Path, checkpoint.VerifiedCount, fileMap.Files.Count)); + } + } + + private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment) + { + var normalizedPath = UpdatePathGuard.NormalizeRelativePath(file.Path); + if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var targetPath = Path.Combine(targetDeployment, normalizedPath); + UpdatePathGuard.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); + UpdatePathGuard.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 : UpdatePathGuard.NormalizeRelativePath(file.ArchivePath); + var extractedPath = Path.Combine(paths.ExtractRoot, archiveRelative); + UpdatePathGuard.EnsurePathWithinRoot(extractedPath, paths.ExtractRoot); + if (!File.Exists(extractedPath)) + { + throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'."); + } + + File.Copy(extractedPath, targetPath, overwrite: true); + } + + private void PrepareExtractRoot() + { + if (Directory.Exists(paths.ExtractRoot)) + { + Directory.Delete(paths.ExtractRoot, true); + } + + Directory.CreateDirectory(paths.ExtractRoot); + } + + private void TryDeleteExtractRoot() + { + try + { + if (Directory.Exists(paths.ExtractRoot)) + { + Directory.Delete(paths.ExtractRoot, true); + } + } + catch + { + } + } + + private static SnapshotMetadata BuildSnapshot( + bool canResume, + InstallCheckpoint? existingCheckpoint, + string currentVersion, + string targetVersion, + string? currentDeployment, + string targetDeployment) => + new() + { + SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), + SourceVersion = currentVersion, + TargetVersion = targetVersion, + CreatedAt = DateTimeOffset.UtcNow, + SourceDirectory = currentDeployment ?? string.Empty, + TargetDirectory = targetDeployment, + Status = "pending" + }; + + private static InstallCheckpoint BuildCheckpoint( + SnapshotMetadata snapshot, + string currentVersion, + string targetVersion, + string? currentDeployment, + string targetDeployment) => + new() + { + SnapshotId = snapshot.SnapshotId, + SourceVersion = currentVersion, + TargetVersion = targetVersion, + SourceDirectory = currentDeployment, + TargetDirectory = targetDeployment, + IsInitialDeployment = false + }; + + private static bool NeedsVerification(UpdateFileEntry file) + { + return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(file.Sha256); + } +} diff --git a/LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs b/LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs new file mode 100644 index 0000000..4f20f22 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class PendingUpdateDetector( + DeploymentLocator deploymentLocator, + UpdateEnginePaths paths, + UpdateSignatureVerifier signatureVerifier) +{ + public LauncherResult CheckPendingUpdate() + { + if (File.Exists(paths.PlondsFileMapPath) && File.Exists(paths.PlondsSignaturePath)) + { + var pdcFileMapText = File.ReadAllText(paths.PlondsFileMapPath); + var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PlondsFileMap); + if (pdcFileMap is null) + { + return UpdateEngineResults.Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid."); + } + + var pdcVerified = signatureVerifier.Verify( + paths.PlondsFileMapPath, + paths.PlondsSignaturePath, + UpdateEnginePaths.PlondsSignatureFileName); + if (!pdcVerified.Success) + { + return UpdateEngineResults.Failed("update.check", "signature_failed", pdcVerified.Message); + } + + var pdcMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath); + return new LauncherResult + { + Success = true, + Stage = "update.check", + Code = "available", + Message = "Pending PLONDS update is available.", + CurrentVersion = deploymentLocator.GetCurrentVersion(), + TargetVersion = PlondsManifestParser.ResolveTargetVersion(pdcFileMap, pdcMetadata) + }; + } + + if (!File.Exists(paths.FileMapPath) || !File.Exists(paths.ArchivePath)) + { + return new LauncherResult + { + Success = true, + Stage = "update.check", + Code = "noop", + Message = "No pending update." + }; + } + + var fileMapText = File.ReadAllText(paths.FileMapPath); + var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); + if (fileMap is null) + { + return UpdateEngineResults.Failed("update.check", "invalid_manifest", "files.json is invalid."); + } + + var verified = signatureVerifier.Verify(paths.FileMapPath, paths.SignaturePath, UpdateEnginePaths.SignatureFileName); + if (!verified.Success) + { + return UpdateEngineResults.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 LauncherResult ValidateIncomingState() + { + if (File.Exists(paths.ApplyLockPath)) + { + return UpdateEngineResults.Failed("update.apply", "lock_conflict", "Another update apply operation is already in progress."); + } + + if (!File.Exists(paths.DeploymentLockPath)) + { + return UpdateEngineResults.Failed("update.apply", "staging_incomplete", "Deployment lock is missing. Please redownload the update."); + } + + var hasPlondsMap = File.Exists(paths.PlondsFileMapPath); + var hasLegacyMap = File.Exists(paths.FileMapPath); + if (hasPlondsMap && !File.Exists(paths.DownloadMarkerPath)) + { + return UpdateEngineResults.Failed("update.apply", "staging_incomplete", "Download marker is missing for pending PLONDS update."); + } + + if (!hasPlondsMap && !hasLegacyMap) + { + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "noop", + Message = "No update payload found." + }; + } + + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "ok", + Message = "Incoming update state validated." + }; + } +} diff --git a/LanMountainDesktop.Launcher/Update/PlondsManifestParser.cs b/LanMountainDesktop.Launcher/Update/PlondsManifestParser.cs new file mode 100644 index 0000000..7d48533 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/PlondsManifestParser.cs @@ -0,0 +1,416 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal static class PlondsManifestParser +{ + public static List CollectFileEntries(PlondsFileMap fileMap) + { + var files = new List(); + if (fileMap.Files is { Count: > 0 }) + { + files.AddRange(fileMap.Files); + } + + if (fileMap.Components is null) + { + return files; + } + + foreach (var component in fileMap.Components) + { + if (component.Files is { Count: > 0 }) + { + files.AddRange(component.Files); + } + } + + return files; + } + + public static void PopulateFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection files) + { + if (string.IsNullOrWhiteSpace(fileMapJson)) + { + return; + } + + using var document = JsonDocument.Parse(fileMapJson); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return; + } + + fileMap.FromVersion ??= ReadStringIgnoreCase(root, "fromversion"); + fileMap.ToVersion ??= ReadStringIgnoreCase(root, "toversion"); + fileMap.Version ??= ReadStringIgnoreCase(root, "version"); + fileMap.Platform ??= ReadStringIgnoreCase(root, "platform"); + fileMap.Arch ??= ReadStringIgnoreCase(root, "arch"); + fileMap.DistributionId ??= ReadStringIgnoreCase(root, "distributionid"); + PopulateMetadata(root, fileMap.Metadata); + + if (TryGetPropertyIgnoreCase(root, "files", out var rootFilesNode)) + { + ParseFilesNode(rootFilesNode, null, files); + } + + if (TryGetPropertyIgnoreCase(root, "components", out var componentsNode)) + { + ParseComponentsNode(componentsNode, files); + } + } + + public static PlondsUpdateMetadata? LoadMetadata(string path) + { + if (!File.Exists(path)) + { + return null; + } + + try + { + var text = File.ReadAllText(path); + return string.IsNullOrWhiteSpace(text) + ? null + : JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata); + } + catch + { + return null; + } + } + + public static string? ResolveSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata) + { + return FirstNonEmpty( + metadata?.FromVersion, + fileMap.FromVersion, + TryGetMetadataValue(fileMap.Metadata, "fromVersion"), + TryGetMetadataValue(fileMap.Metadata, "sourceVersion")); + } + + public static string? ResolveTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata) + { + return FirstNonEmpty( + metadata?.ToVersion, + fileMap.ToVersion, + fileMap.Version, + TryGetMetadataValue(fileMap.Metadata, "toVersion"), + TryGetMetadataValue(fileMap.Metadata, "targetVersion")); + } + + public static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected) + { + expected = []; + if (file.Sha512Bytes is { Length: > 0 }) + { + expected = file.Sha512Bytes; + return true; + } + + if (file.Hash is not null) + { + if (file.Hash.Bytes is { Length: > 0 }) + { + expected = file.Hash.Bytes; + return true; + } + + if ((string.IsNullOrWhiteSpace(file.Hash.Algorithm) || + file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) && + UpdateHash.TryParseHashBytes(file.Hash.Value, out expected)) + { + return true; + } + } + + if (UpdateHash.TryParseHashBytes(file.Sha512, out expected)) + { + return true; + } + + return UpdateHash.TryParseHashBytes(file.Sha512Base64, out expected); + } + + public static bool TryGetExpectedObjectSha512(PlondsFileEntry file, out byte[] expected) + { + expected = []; + if (file.Hash is null) + { + return false; + } + + if (file.Hash.Bytes is { Length: > 0 }) + { + expected = file.Hash.Bytes; + return true; + } + + if (!string.IsNullOrWhiteSpace(file.Hash.Algorithm) && + !file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return UpdateHash.TryParseHashBytes(file.Hash.Value, out expected); + } + + private static void ParseComponentsNode(JsonElement componentsNode, ICollection files) + { + if (componentsNode.ValueKind == JsonValueKind.Object) + { + foreach (var component in componentsNode.EnumerateObject()) + { + if (component.Value.ValueKind == JsonValueKind.Object && + TryGetPropertyIgnoreCase(component.Value, "files", out var componentFilesNode)) + { + ParseFilesNode(componentFilesNode, component.Name, files); + } + } + + return; + } + + if (componentsNode.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var component in componentsNode.EnumerateArray()) + { + if (component.ValueKind != JsonValueKind.Object) + { + continue; + } + + var componentName = ReadStringIgnoreCase(component, "name"); + if (TryGetPropertyIgnoreCase(component, "files", out var componentFilesNode)) + { + ParseFilesNode(componentFilesNode, componentName, files); + } + } + } + + private static void ParseFilesNode(JsonElement filesNode, string? componentName, ICollection files) + { + if (filesNode.ValueKind == JsonValueKind.Object) + { + foreach (var fileEntry in filesNode.EnumerateObject()) + { + if (fileEntry.Value.ValueKind == JsonValueKind.Object && + TryCreateFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed)) + { + files.Add(parsed); + } + } + + return; + } + + if (filesNode.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var fileEntry in filesNode.EnumerateArray()) + { + if (fileEntry.ValueKind == JsonValueKind.Object && + TryCreateFileEntry(ReadStringIgnoreCase(fileEntry, "path"), componentName, fileEntry, out var parsed)) + { + files.Add(parsed); + } + } + } + + private static bool TryCreateFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry) + { + entry = new PlondsFileEntry(); + var path = ReadStringIgnoreCase(node, "path"); + if (string.IsNullOrWhiteSpace(path)) + { + path = fallbackPath; + } + + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var archiveSha512 = ReadByteArrayIgnoreCase(node, "archivesha512"); + var archiveSha512Text = ReadStringIgnoreCase(node, "archivesha512"); + entry = new PlondsFileEntry + { + Path = path, + Action = FirstNonEmpty(ReadStringIgnoreCase(node, "action"), "replace"), + Url = ReadStringIgnoreCase(node, "archivedownloadurl") ?? ReadStringIgnoreCase(node, "downloadurl") ?? ReadStringIgnoreCase(node, "url"), + ObjectUrl = ReadStringIgnoreCase(node, "objecturl"), + ObjectPath = ReadStringIgnoreCase(node, "objectpath") ?? ReadStringIgnoreCase(node, "archivepath"), + ObjectKey = ReadStringIgnoreCase(node, "objectkey"), + ArchivePath = ReadStringIgnoreCase(node, "archivepath"), + Sha256 = ReadStringIgnoreCase(node, "sha256") ?? ReadStringIgnoreCase(node, "filesha256"), + Sha512 = ReadStringIgnoreCase(node, "filesha512") ?? ReadStringIgnoreCase(node, "sha512"), + Sha512Bytes = ReadByteArrayIgnoreCase(node, "filesha512") ?? ReadByteArrayIgnoreCase(node, "sha512"), + Metadata = BuildMetadata(node, componentName) + }; + + if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text)) + { + entry.Hash = new PlondsHashDescriptor + { + Algorithm = "sha512", + Bytes = archiveSha512, + Value = archiveSha512Text ?? (archiveSha512 is { Length: > 0 } + ? Convert.ToHexString(archiveSha512).ToLowerInvariant() + : null) + }; + } + else if (TryGetPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object) + { + entry.Hash = new PlondsHashDescriptor + { + Algorithm = ReadStringIgnoreCase(hashNode, "algorithm"), + Value = ReadStringIgnoreCase(hashNode, "value"), + Bytes = ReadByteArrayIgnoreCase(hashNode, "bytes") + }; + } + + return true; + } + + private static Dictionary BuildMetadata(JsonElement node, string? componentName) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(componentName)) + { + metadata["component"] = componentName; + } + + PopulateMetadata(node, metadata); + return metadata; + } + + private static void PopulateMetadata(JsonElement node, Dictionary metadata) + { + if (!TryGetPropertyIgnoreCase(node, "metadata", out var metadataNode) || + metadataNode.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (var property in metadataNode.EnumerateObject()) + { + if (property.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + continue; + } + + var value = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : property.Value.ToString(); + if (!string.IsNullOrWhiteSpace(value)) + { + metadata[property.Name] = value; + } + } + } + + private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value) + { + if (node.ValueKind == JsonValueKind.Object) + { + foreach (var property in node.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + } + + value = default; + return false; + } + + private static string? ReadStringIgnoreCase(JsonElement node, string propertyName) + { + if (!TryGetPropertyIgnoreCase(node, propertyName, out var value)) + { + return null; + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString() + : value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? null + : value.ToString(); + } + + private static byte[]? ReadByteArrayIgnoreCase(JsonElement node, string propertyName) + { + return TryGetPropertyIgnoreCase(node, propertyName, out var value) + ? ParseByteArrayValue(value) + : null; + } + + private static byte[]? ParseByteArrayValue(JsonElement value) + { + if (value.ValueKind == JsonValueKind.String) + { + return UpdateHash.TryParseHashBytes(value.GetString(), out var parsed) ? parsed : null; + } + + if (value.ValueKind != JsonValueKind.Array) + { + return null; + } + + var bytes = new byte[value.GetArrayLength()]; + var index = 0; + foreach (var element in value.EnumerateArray()) + { + if (!element.TryGetInt32(out var number) || number < byte.MinValue || number > byte.MaxValue) + { + return null; + } + + bytes[index++] = (byte)number; + } + + return bytes; + } + + private static string? TryGetMetadataValue(Dictionary? metadata, string key) + { + if (metadata is null || metadata.Count == 0) + { + return null; + } + + foreach (var pair in metadata) + { + if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(pair.Value)) + { + return pair.Value; + } + } + + return null; + } + + private static string? FirstNonEmpty(params string?[] values) + { + foreach (var value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } +} diff --git a/LanMountainDesktop.Launcher/Update/PlondsPayloadResolver.cs b/LanMountainDesktop.Launcher/Update/PlondsPayloadResolver.cs new file mode 100644 index 0000000..90ab9cc --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/PlondsPayloadResolver.cs @@ -0,0 +1,97 @@ +using System.IO.Compression; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths) +{ + public string ResolveObjectPath(PlondsFileEntry file) + { + var candidates = new List(); + AddPathCandidates(candidates, file.ObjectPath); + AddPathCandidates(candidates, file.ObjectKey); + AddPathCandidates(candidates, file.ArchivePath); + AddPathCandidates(candidates, file.ObjectUrl); + AddPathCandidates(candidates, file.Url); + + if (PlondsManifestParser.TryGetExpectedObjectSha512(file, out var expectedSha512) || + PlondsManifestParser.TryGetExpectedSha512(file, out expectedSha512)) + { + var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant(); + AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex)); + if (hashHex.Length > 2) + { + AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex)); + AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..])); + } + + AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, $"{hashHex}.gz")); + } + + foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var fullPath = Path.GetFullPath(Path.Combine(paths.IncomingRoot, relativePath)); + if (!fullPath.StartsWith(Path.GetFullPath(paths.IncomingRoot), StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (File.Exists(fullPath)) + { + return fullPath; + } + } + + throw new FileNotFoundException($"Unable to resolve object payload for '{file.Path}'."); + } + + public static byte[]? TryInflateGzip(byte[] payload) + { + try + { + using var input = new MemoryStream(payload, writable: false); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } + catch + { + return null; + } + } + + private static void AddPathCandidates(ICollection candidates, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + var normalized = value.Trim(); + if (Uri.TryCreate(normalized, UriKind.Absolute, out var absoluteUri)) + { + normalized = Uri.UnescapeDataString(absoluteUri.AbsolutePath); + } + + normalized = normalized.TrimStart('/', '\\'); + if (string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + candidates.Add(normalized); + + if (!normalized.StartsWith($"{UpdateEnginePaths.PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + { + candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, normalized)); + } + + var fileName = Path.GetFileName(normalized); + if (!string.IsNullOrWhiteSpace(fileName)) + { + candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, fileName)); + } + } +} diff --git a/LanMountainDesktop.Launcher/Update/PlondsUpdateApplier.cs b/LanMountainDesktop.Launcher/Update/PlondsUpdateApplier.cs new file mode 100644 index 0000000..a6dc7f8 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/PlondsUpdateApplier.cs @@ -0,0 +1,374 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; +using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class PlondsUpdateApplier( + DeploymentLocator deploymentLocator, + UpdateEnginePaths paths, + UpdateSignatureVerifier signatureVerifier, + IUpdateProgressReporter progressReporter, + UpdateSnapshotStore snapshotStore, + InstallCheckpointStore checkpointStore, + DeploymentActivator deploymentActivator, + IncomingArtifactsCleaner incomingCleaner, + PlondsPayloadResolver payloadResolver) +{ + public async Task ApplyAsync() + { + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0)); + var verifyResult = signatureVerifier.Verify(paths.PlondsFileMapPath, paths.PlondsSignaturePath, UpdateEnginePaths.PlondsSignatureFileName); + if (!verifyResult.Success) + { + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); + return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message); + } + + var fileMapText = await File.ReadAllTextAsync(paths.PlondsFileMapPath).ConfigureAwait(false); + var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap(); + var fileEntries = PlondsManifestParser.CollectFileEntries(fileMap); + if (fileEntries.Count == 0) + { + PlondsManifestParser.PopulateFromRawJson(fileMapText, fileMap, fileEntries); + } + + if (fileEntries.Count == 0) + { + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false)); + return UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found."); + } + + var pdcMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath); + var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory(); + var currentVersion = deploymentLocator.GetCurrentVersion(); + var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion; + var expectedSourceVersion = PlondsManifestParser.ResolveSourceVersion(fileMap, pdcMetadata); + if (!string.IsNullOrWhiteSpace(expectedSourceVersion) && + !string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase)) + { + return UpdateEngineResults.Failed( + "update.apply", + "version_mismatch", + $"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}."); + } + + var targetVersion = PlondsManifestParser.ResolveTargetVersion(fileMap, pdcMetadata); + if (string.IsNullOrWhiteSpace(targetVersion)) + { + targetVersion = sourceVersion; + } + + var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment); + var existingCheckpoint = checkpointStore.Load(); + var canResume = existingCheckpoint is not null + && string.Equals(existingCheckpoint.SourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(existingCheckpoint.TargetDirectory) + && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); + + if (existingCheckpoint is not null && !canResume) + { + return UpdateEngineResults.Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); + } + + var targetDeployment = canResume + ? existingCheckpoint!.TargetDirectory + : deploymentLocator.BuildNextDeploymentDirectory(targetVersion!); + var snapshot = BuildSnapshot(canResume, existingCheckpoint, sourceVersion, targetVersion, currentDeployment, targetDeployment); + var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId); + var checkpoint = canResume + ? existingCheckpoint! + : BuildCheckpoint(snapshot, sourceVersion, targetVersion, currentDeployment, targetDeployment, isInitialDeployment); + + try + { + snapshotStore.Save(snapshotPath, snapshot); + if (!canResume) + { + if (Directory.Exists(targetDeployment)) + { + Directory.Delete(targetDeployment, true); + } + + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count)); + Directory.CreateDirectory(targetDeployment); + File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); + } + + checkpointStore.Save(checkpoint); + ApplyFiles(fileEntries, currentDeployment, targetDeployment, checkpoint); + VerifyFiles(fileEntries, targetDeployment, checkpoint); + + if (isInitialDeployment) + { + File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty); + var partialMarker = Path.Combine(targetDeployment, ".partial"); + if (File.Exists(partialMarker)) + { + File.Delete(partialMarker); + } + } + else + { + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count)); + deploymentActivator.Activate(currentDeployment!, targetDeployment); + } + + snapshot.Status = "applied"; + snapshotStore.Save(snapshotPath, snapshot); + incomingCleaner.Cleanup(); + deploymentActivator.RetainDeploymentsForRollback(); + + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count)); + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false)); + + return new LauncherResult + { + Success = true, + Stage = "update.apply", + Code = "ok", + Message = $"Updated to {targetVersion}.", + CurrentVersion = sourceVersion, + TargetVersion = targetVersion + }; + } + catch (Exception ex) + { + return HandleFailure(ex, isInitialDeployment, targetDeployment, snapshot, snapshotPath, sourceVersion, targetVersion); + } + finally + { + checkpointStore.Delete(); + } + } + + private void ApplyFiles(IReadOnlyList fileEntries, string? currentDeployment, string targetDeployment, InstallCheckpoint checkpoint) + { + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count)); + for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++) + { + var entry = fileEntries[fileIndex]; + ApplyFileEntry(entry, currentDeployment, targetDeployment); + checkpoint.AppliedCount = fileIndex + 1; + checkpointStore.Save(checkpoint); + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count)); + } + } + + private void VerifyFiles(IReadOnlyList fileEntries, string targetDeployment, InstallCheckpoint checkpoint) + { + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count)); + for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++) + { + var entry = fileEntries[verifyIndex]; + VerifyFileEntry(entry, targetDeployment); + checkpoint.VerifiedCount = verifyIndex + 1; + checkpointStore.Save(checkpoint); + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count)); + } + } + + private void ApplyFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment) + { + var normalizedPath = UpdatePathGuard.NormalizeRelativePath(file.Path); + var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; + if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var targetPath = Path.Combine(targetDeployment, normalizedPath); + UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment); + var targetDir = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(targetDir)) + { + Directory.CreateDirectory(targetDir); + } + + if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase)) + { + CopyReusedFile(file, currentDeployment, normalizedPath, targetPath); + return; + } + + var objectPath = payloadResolver.ResolveObjectPath(file); + var objectBytes = File.ReadAllBytes(objectPath); + var restoredBytes = PlondsPayloadResolver.TryInflateGzip(objectBytes) ?? objectBytes; + File.WriteAllBytes(targetPath, restoredBytes); + ApplyUnixFileModeIfPresent(targetPath, file); + } + + private static void CopyReusedFile(PlondsFileEntry file, string? currentDeployment, string normalizedPath, string targetPath) + { + if (string.IsNullOrWhiteSpace(currentDeployment)) + { + throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available."); + } + + var sourcePath = Path.Combine(currentDeployment, normalizedPath); + UpdatePathGuard.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); + ApplyUnixFileModeIfPresent(targetPath, file); + } + + private static void VerifyFileEntry(PlondsFileEntry file, string targetDeployment) + { + var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; + if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var targetPath = Path.Combine(targetDeployment, UpdatePathGuard.NormalizeRelativePath(file.Path)); + UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment); + if (!File.Exists(targetPath)) + { + throw new FileNotFoundException($"Expected target file was not created: {file.Path}"); + } + + if (PlondsManifestParser.TryGetExpectedSha512(file, out var expectedSha512)) + { + var actualSha512 = UpdateHash.ComputeSha512(targetPath); + if (!actualSha512.AsSpan().SequenceEqual(expectedSha512)) + { + throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'."); + } + + return; + } + + if (!string.IsNullOrWhiteSpace(file.Sha256)) + { + var expectedSha256 = UpdateHash.NormalizeHashText(file.Sha256); + var actualSha256 = UpdateHash.ComputeSha256Hex(targetPath); + if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'."); + } + } + } + + private LauncherResult HandleFailure( + Exception ex, + bool isInitialDeployment, + string targetDeployment, + SnapshotMetadata snapshot, + string snapshotPath, + string sourceVersion, + string targetVersion) + { + if (isInitialDeployment) + { + TryDeleteDirectory(targetDeployment); + snapshot.Status = "failed"; + snapshotStore.Save(snapshotPath, snapshot); + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false)); + return new LauncherResult + { + Success = false, + Stage = "update.apply", + Code = "initial_deploy_failed", + Message = "Failed to apply initial PLONDS deployment.", + ErrorMessage = ex.Message, + CurrentVersion = "0.0.0", + TargetVersion = targetVersion + }; + } + + progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); + var rollbackResult = deploymentActivator.TryRollbackOnFailure(snapshot); + snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; + snapshotStore.Save(snapshotPath, snapshot); + var errorMessage = rollbackResult.Success + ? ex.Message + : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; + progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success)); + return new LauncherResult + { + Success = false, + Stage = "update.apply", + Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", + Message = rollbackResult.Success + ? "Failed to apply PLONDS update. Rolled back to previous version." + : "Failed to apply PLONDS update and rollback failed.", + ErrorMessage = errorMessage, + CurrentVersion = sourceVersion, + RolledBackTo = rollbackResult.Success ? sourceVersion : null + }; + } + + private static SnapshotMetadata BuildSnapshot( + bool canResume, + InstallCheckpoint? existingCheckpoint, + string sourceVersion, + string targetVersion, + string? currentDeployment, + string targetDeployment) => + new() + { + SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), + SourceVersion = sourceVersion, + TargetVersion = targetVersion, + CreatedAt = DateTimeOffset.UtcNow, + SourceDirectory = currentDeployment ?? string.Empty, + TargetDirectory = targetDeployment, + Status = "pending" + }; + + private static InstallCheckpoint BuildCheckpoint( + SnapshotMetadata snapshot, + string sourceVersion, + string targetVersion, + string? currentDeployment, + string targetDeployment, + bool isInitialDeployment) => + new() + { + SnapshotId = snapshot.SnapshotId, + SourceVersion = sourceVersion, + TargetVersion = targetVersion, + SourceDirectory = currentDeployment, + TargetDirectory = targetDeployment, + IsInitialDeployment = isInitialDeployment + }; + + private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file) + { + if (OperatingSystem.IsWindows() || + !file.Metadata.TryGetValue("unixFileMode", out var rawMode) || + string.IsNullOrWhiteSpace(rawMode)) + { + return; + } + + try + { + var modeValue = Convert.ToInt32(rawMode.Trim(), 8); + File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue); + } + catch + { + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch + { + } + } +} diff --git a/LanMountainDesktop.Launcher/Update/RollbackStrategy.cs b/LanMountainDesktop.Launcher/Update/RollbackStrategy.cs new file mode 100644 index 0000000..508bc07 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/RollbackStrategy.cs @@ -0,0 +1,48 @@ +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class RollbackStrategy( + DeploymentLocator deploymentLocator, + UpdateSnapshotStore snapshotStore, + DeploymentActivator deploymentActivator) +{ + public LauncherResult RollbackLatest() + { + var latest = snapshotStore.LoadLatest(); + if (latest is null) + { + return UpdateEngineResults.Failed("update.rollback", "no_snapshot", "No snapshot found."); + } + + var (snapshotPath, snapshot) = latest.Value; + if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory)) + { + return UpdateEngineResults.Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata."); + } + + if (!Directory.Exists(snapshot.SourceDirectory)) + { + return UpdateEngineResults.Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}"); + } + + var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory(); + if (string.IsNullOrWhiteSpace(currentDeployment)) + { + return UpdateEngineResults.Failed("update.rollback", "no_current_deployment", "Current deployment not found."); + } + + deploymentActivator.Activate(currentDeployment, snapshot.SourceDirectory); + snapshot.Status = "manual_rollback"; + snapshotStore.Save(snapshotPath, snapshot); + + return new LauncherResult + { + Success = true, + Stage = "update.rollback", + Code = "ok", + Message = $"Rolled back to {snapshot.SourceVersion}.", + RolledBackTo = snapshot.SourceVersion + }; + } +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs b/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs index 77cc39d..ed130cb 100644 --- a/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs +++ b/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs @@ -1,114 +1,50 @@ -using System.IO.Compression; -using System.Security.Cryptography; -using System.Text.Json; using LanMountainDesktop.Launcher.Models; -using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Launcher.Update; internal sealed class UpdateEngineFacade : IUpdateEngine { - 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 PlondsFileMapName = "plonds-filemap.json"; - private const string PlondsSignatureFileName = "plonds-filemap.sig"; - private const string PlondsUpdateMetadataName = "plonds-update.json"; - private const string PlondsObjectsDirectoryName = "objects"; - private const string PublicKeyFileName = "public-key.pem"; - - private readonly DeploymentLocator _deploymentLocator; - private readonly IUpdateProgressReporter _progressReporter; - private readonly string _appRoot; - private readonly string _launcherRoot; - private readonly string _incomingRoot; - private readonly string _snapshotsRoot; - private readonly string _installCheckpointPath; + private readonly UpdateEnginePaths _paths; + private readonly PendingUpdateDetector _pendingUpdateDetector; + private readonly LegacyUpdateApplier _legacyUpdateApplier; + private readonly PlondsUpdateApplier _plondsUpdateApplier; + private readonly RollbackStrategy _rollbackStrategy; + private readonly DeploymentActivator _deploymentActivator; + private readonly IncomingArtifactsCleaner _incomingArtifactsCleaner; public UpdateEngineFacade(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) { - _deploymentLocator = deploymentLocator; - _progressReporter = progressReporter ?? new NullUpdateProgressReporter(); - _appRoot = deploymentLocator.GetAppRoot(); - var resolver = new DataLocationResolver(_appRoot); - _launcherRoot = resolver.ResolveLauncherDataPath(); - _incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName); - _snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName); - _installCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(_appRoot); + var reporter = progressReporter ?? new NullUpdateProgressReporter(); + _paths = new UpdateEnginePaths(deploymentLocator.GetAppRoot()); + var signatureVerifier = new UpdateSignatureVerifier(_paths); + var snapshotStore = new UpdateSnapshotStore(_paths); + var checkpointStore = new InstallCheckpointStore(_paths); + _deploymentActivator = new DeploymentActivator(deploymentLocator); + _incomingArtifactsCleaner = new IncomingArtifactsCleaner(_paths); + _pendingUpdateDetector = new PendingUpdateDetector(deploymentLocator, _paths, signatureVerifier); + _legacyUpdateApplier = new LegacyUpdateApplier( + deploymentLocator, + _paths, + signatureVerifier, + reporter, + snapshotStore, + checkpointStore, + _deploymentActivator, + _incomingArtifactsCleaner); + _plondsUpdateApplier = new PlondsUpdateApplier( + deploymentLocator, + _paths, + signatureVerifier, + reporter, + snapshotStore, + checkpointStore, + _deploymentActivator, + _incomingArtifactsCleaner, + new PlondsPayloadResolver(_paths)); + _rollbackStrategy = new RollbackStrategy(deploymentLocator, snapshotStore, _deploymentActivator); } - public LauncherResult CheckPendingUpdate() - { - var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName); - var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName); - var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName); - if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) - { - var pdcFileMapText = File.ReadAllText(pdcFileMapPath); - var pdcFileMap = JsonSerializer.Deserialize(pdcFileMapText, AppJsonContext.Default.PlondsFileMap); - if (pdcFileMap is null) - { - return Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid."); - } - - var pdcVerified = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName); - if (!pdcVerified.Success) - { - return Failed("update.check", "signature_failed", pdcVerified.Message); - } - - var pdcMetadata = LoadPlondsUpdateMetadata(pdcUpdatePath); - return new LauncherResult - { - Success = true, - Stage = "update.check", - Code = "available", - Message = "Pending PLONDS update is available.", - CurrentVersion = _deploymentLocator.GetCurrentVersion(), - TargetVersion = ResolvePlondsTargetVersion(pdcFileMap, pdcMetadata) - }; - } - - 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, AppJsonContext.Default.SignedFileMap); - if (fileMap is null) - { - return Failed("update.check", "invalid_manifest", "files.json is invalid."); - } - - var verified = VerifySignature(fileMapPath, signaturePath, SignatureFileName); - 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 LauncherResult CheckPendingUpdate() => _pendingUpdateDetector.CheckPendingUpdate(); public Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken) { @@ -128,1722 +64,56 @@ internal sealed class UpdateEngineFacade : IUpdateEngine public async Task ApplyPendingUpdateAsync() { - Directory.CreateDirectory(_incomingRoot); - Directory.CreateDirectory(_snapshotsRoot); + Directory.CreateDirectory(_paths.IncomingRoot); + Directory.CreateDirectory(_paths.SnapshotsRoot); - var stateValidation = ValidateIncomingState(); - if (!stateValidation.Success) + var stateValidation = _pendingUpdateDetector.ValidateIncomingState(); + if (!stateValidation.Success || stateValidation.Code == "noop") { return stateValidation; } - var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot); try { - File.WriteAllText(applyLockPath, DateTimeOffset.UtcNow.ToString("O")); + File.WriteAllText(_paths.ApplyLockPath, DateTimeOffset.UtcNow.ToString("O")); } catch (Exception ex) { - return Failed("update.apply", "lock_conflict", $"Failed to acquire apply lock: {ex.Message}"); + return UpdateEngineResults.Failed("update.apply", "lock_conflict", $"Failed to acquire apply lock: {ex.Message}"); } try { - var pdcFileMapPath = Path.Combine(_incomingRoot, PlondsFileMapName); - var pdcSignaturePath = Path.Combine(_incomingRoot, PlondsSignatureFileName); - var pdcUpdatePath = Path.Combine(_incomingRoot, PlondsUpdateMetadataName); - if (File.Exists(pdcFileMapPath) && File.Exists(pdcSignaturePath)) + if (_paths.HasPlondsPayload) { - return await ApplyPendingPlondsUpdateAsync(pdcFileMapPath, pdcSignaturePath, pdcUpdatePath); + return await _plondsUpdateApplier.ApplyAsync().ConfigureAwait(false); } - 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." - }; - } - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0)); - var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName); - if (!verifyResult.Success) - { - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); - return Failed("update.apply", "signature_failed", verifyResult.Message); - } - - var fileMapText = await File.ReadAllTextAsync(fileMapPath); - var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); - if (fileMap is null || fileMap.Files.Count == 0) - { - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false)); - return Failed("update.apply", "invalid_manifest", "No update file entries were found."); - } - - var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); - if (string.IsNullOrWhiteSpace(currentDeployment)) - { - // Initial install path: no current deployment exists, so apply the staged package directly. - } - - 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 existingCheckpoint = LoadInstallCheckpoint(); - var canResume = existingCheckpoint is not null - && string.Equals(existingCheckpoint.SourceVersion, currentVersion, StringComparison.OrdinalIgnoreCase) - && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) - && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) - && Directory.Exists(existingCheckpoint.TargetDirectory) - && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); - - if (existingCheckpoint is not null && !canResume) - { - return Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); - } - - var targetDeployment = canResume - ? existingCheckpoint!.TargetDirectory - : _deploymentLocator.BuildNextDeploymentDirectory(targetVersion); - var partialMarker = Path.Combine(targetDeployment, ".partial"); - var snapshot = new SnapshotMetadata - { - SnapshotId = canResume ? existingCheckpoint!.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 checkpoint = canResume - ? existingCheckpoint! - : new InstallCheckpoint - { - SnapshotId = snapshot.SnapshotId, - SourceVersion = currentVersion, - TargetVersion = targetVersion, - SourceDirectory = currentDeployment, - TargetDirectory = targetDeployment, - IsInitialDeployment = false, - AppliedCount = 0, - VerifiedCount = 0 - }; - - 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); - - if (!canResume) - { - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count)); - Directory.CreateDirectory(targetDeployment); - File.WriteAllText(partialMarker, string.Empty); - } - - SaveInstallCheckpoint(checkpoint); - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, checkpoint.AppliedCount, fileMap.Files.Count)); - for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileMap.Files.Count; fileIndex++) - { - var file = fileMap.Files[fileIndex]; - ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot); - checkpoint.AppliedCount = fileIndex + 1; - SaveInstallCheckpoint(checkpoint); - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (checkpoint.AppliedCount * 30 / fileMap.Files.Count), file.Path, checkpoint.AppliedCount, fileMap.Files.Count)); - } - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, checkpoint.VerifiedCount, fileMap.Files.Count)); - for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileMap.Files.Count; verifyIndex++) - { - var file = fileMap.Files[verifyIndex]; - if (!NeedsVerification(file)) - { - checkpoint.VerifiedCount = verifyIndex + 1; - SaveInstallCheckpoint(checkpoint); - 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}'."); - } - - checkpoint.VerifiedCount = verifyIndex + 1; - SaveInstallCheckpoint(checkpoint); - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileMap.Files.Count), file.Path, checkpoint.VerifiedCount, fileMap.Files.Count)); - } - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count)); - ActivateDeployment(currentDeployment, targetDeployment); - - snapshot.Status = "applied"; - SaveSnapshot(snapshotPath, snapshot); - CleanupIncomingArtifacts(); - RetainDeploymentsForRollback(); - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count)); - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false)); - - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "ok", - Message = $"Updated to {targetVersion}.", - CurrentVersion = currentVersion, - TargetVersion = targetVersion - }; - } - catch (Exception ex) - { - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); - var rollbackResult = TryRollbackOnFailure(snapshot); - snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; - SaveSnapshot(snapshotPath, snapshot); - var errorMessage = rollbackResult.Success - ? ex.Message - : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, errorMessage, rollbackResult.Success)); - return new LauncherResult - { - Success = false, - Stage = "update.apply", - Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", - Message = rollbackResult.Success - ? "Failed to apply update. Rolled back to previous version." - : "Failed to apply update and rollback failed.", - ErrorMessage = errorMessage, - CurrentVersion = currentVersion, - RolledBackTo = rollbackResult.Success ? currentVersion : null - }; - } - finally - { - DeleteInstallCheckpoint(); - try - { - if (Directory.Exists(extractRoot)) - { - Directory.Delete(extractRoot, true); - } - } - catch - { - } - } + return await _legacyUpdateApplier.ApplyAsync().ConfigureAwait(false); } finally { - try - { - if (File.Exists(applyLockPath)) - { - File.Delete(applyLockPath); - } - } - catch - { - } + TryDeleteApplyLock(); } } - private LauncherResult ValidateIncomingState() - { - var applyLockPath = ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(_appRoot); - if (File.Exists(applyLockPath)) - { - return Failed("update.apply", "lock_conflict", "Another update apply operation is already in progress."); - } + public LauncherResult RollbackLatest() => _rollbackStrategy.RollbackLatest(); - var deploymentLockPath = ContractsUpdate.UpdatePaths.GetDeploymentLockPath(_appRoot); - if (!File.Exists(deploymentLockPath)) - { - return Failed("update.apply", "staging_incomplete", "Deployment lock is missing. Please redownload the update."); - } + public void CleanupDestroyedDeployments() => _deploymentActivator.RetainDeploymentsForRollback(); - var markerPath = ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(_appRoot); - var hasPlondsMap = File.Exists(Path.Combine(_incomingRoot, PlondsFileMapName)); - var hasLegacyMap = File.Exists(Path.Combine(_incomingRoot, SignedFileMapName)); - if (hasPlondsMap && !File.Exists(markerPath)) - { - return Failed("update.apply", "staging_incomplete", "Download marker is missing for pending PLONDS update."); - } + public void CleanupIncomingArtifacts() => _incomingArtifactsCleaner.Cleanup(); - if (!hasPlondsMap && !hasLegacyMap) - { - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "noop", - Message = "No update payload found." - }; - } - - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "ok", - Message = "Incoming update state validated." - }; - } - - private async Task ApplyPendingPlondsUpdateAsync( - string pdcFileMapPath, - string pdcSignaturePath, - string pdcUpdatePath) - { - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0)); - var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName); - if (!verifyResult.Success) - { - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); - return Failed("update.apply", "signature_failed", verifyResult.Message); - } - - var fileMapText = await File.ReadAllTextAsync(pdcFileMapPath).ConfigureAwait(false); - var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap(); - var fileEntries = CollectPlondsFileEntries(fileMap); - if (fileEntries.Count == 0) - { - PopulatePlondsManifestFromRawJson(fileMapText, fileMap, fileEntries); - } - - if (fileEntries.Count == 0) - { - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false)); - return Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found."); - } - - var pdcMetadata = LoadPlondsUpdateMetadata(pdcUpdatePath); - - var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory(); - var currentVersion = _deploymentLocator.GetCurrentVersion(); - var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion; - var expectedSourceVersion = ResolvePlondsSourceVersion(fileMap, pdcMetadata); - if (!string.IsNullOrWhiteSpace(expectedSourceVersion) && - !string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase)) - { - return Failed( - "update.apply", - "version_mismatch", - $"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}."); - } - - var targetVersion = ResolvePlondsTargetVersion(fileMap, pdcMetadata); - if (string.IsNullOrWhiteSpace(targetVersion)) - { - targetVersion = sourceVersion; - } - - var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment); - var existingCheckpoint = LoadInstallCheckpoint(); - var canResume = existingCheckpoint is not null - && string.Equals(existingCheckpoint.SourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase) - && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) - && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) - && Directory.Exists(existingCheckpoint.TargetDirectory) - && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); - - if (existingCheckpoint is not null && !canResume) - { - return Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); - } - - var targetDeployment = canResume - ? existingCheckpoint!.TargetDirectory - : _deploymentLocator.BuildNextDeploymentDirectory(targetVersion!); - var partialMarker = Path.Combine(targetDeployment, ".partial"); - var snapshot = new SnapshotMetadata - { - SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), - SourceVersion = sourceVersion, - TargetVersion = targetVersion, - CreatedAt = DateTimeOffset.UtcNow, - SourceDirectory = currentDeployment ?? string.Empty, - TargetDirectory = targetDeployment, - Status = "pending" - }; - var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json"); - - var checkpoint = canResume - ? existingCheckpoint! - : new InstallCheckpoint - { - SnapshotId = snapshot.SnapshotId, - SourceVersion = sourceVersion, - TargetVersion = targetVersion, - SourceDirectory = currentDeployment, - TargetDirectory = targetDeployment, - IsInitialDeployment = isInitialDeployment, - AppliedCount = 0, - VerifiedCount = 0 - }; - - try - { - SaveSnapshot(snapshotPath, snapshot); - - if (!canResume) - { - if (Directory.Exists(targetDeployment)) - { - Directory.Delete(targetDeployment, true); - } - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count)); - Directory.CreateDirectory(targetDeployment); - File.WriteAllText(partialMarker, string.Empty); - } - - SaveInstallCheckpoint(checkpoint); - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count)); - for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++) - { - var entry = fileEntries[fileIndex]; - ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment); - checkpoint.AppliedCount = fileIndex + 1; - SaveInstallCheckpoint(checkpoint); - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count)); - } - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count)); - for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++) - { - var entry = fileEntries[verifyIndex]; - VerifyPlondsFileEntry(entry, targetDeployment); - checkpoint.VerifiedCount = verifyIndex + 1; - SaveInstallCheckpoint(checkpoint); - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count)); - } - - if (isInitialDeployment) - { - File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty); - if (File.Exists(partialMarker)) - { - File.Delete(partialMarker); - } - } - else - { - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count)); - ActivateDeployment(currentDeployment!, targetDeployment); - } - - snapshot.Status = "applied"; - SaveSnapshot(snapshotPath, snapshot); - CleanupIncomingArtifacts(); - RetainDeploymentsForRollback(); - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count)); - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false)); - - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "ok", - Message = $"Updated to {targetVersion}.", - CurrentVersion = sourceVersion, - TargetVersion = targetVersion - }; - } - catch (Exception ex) - { - if (isInitialDeployment) - { - try - { - if (Directory.Exists(targetDeployment)) - { - Directory.Delete(targetDeployment, true); - } - } - catch - { - } - - snapshot.Status = "failed"; - SaveSnapshot(snapshotPath, snapshot); - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false)); - return new LauncherResult - { - Success = false, - Stage = "update.apply", - Code = "initial_deploy_failed", - Message = "Failed to apply initial PLONDS deployment.", - ErrorMessage = ex.Message, - CurrentVersion = "0.0.0", - TargetVersion = targetVersion - }; - } - - _progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); - var rollbackResult = TryRollbackOnFailure(snapshot); - snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; - SaveSnapshot(snapshotPath, snapshot); - var errorMessage = rollbackResult.Success - ? ex.Message - : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; - _progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success)); - return new LauncherResult - { - Success = false, - Stage = "update.apply", - Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", - Message = rollbackResult.Success - ? "Failed to apply PLONDS update. Rolled back to previous version." - : "Failed to apply PLONDS update and rollback failed.", - ErrorMessage = errorMessage, - CurrentVersion = sourceVersion, - RolledBackTo = rollbackResult.Success ? sourceVersion : null - }; - } - finally - { - DeleteInstallCheckpoint(); - } - } - - private void ApplyPlondsFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment) - { - var normalizedPath = NormalizeRelativePath(file.Path); - var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; - if (string.Equals(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(action, "reuse", StringComparison.OrdinalIgnoreCase)) - { - if (string.IsNullOrWhiteSpace(currentDeployment)) - { - throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available."); - } - - 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); - ApplyUnixFileModeIfPresent(targetPath, file); - return; - } - - var objectPath = ResolvePlondsObjectPath(file); - var objectBytes = File.ReadAllBytes(objectPath); - var restoredBytes = TryInflateGzip(objectBytes) ?? objectBytes; - File.WriteAllBytes(targetPath, restoredBytes); - ApplyUnixFileModeIfPresent(targetPath, file); - } - - private void VerifyPlondsFileEntry(PlondsFileEntry file, string targetDeployment) - { - var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; - if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - var targetPath = Path.Combine(targetDeployment, NormalizeRelativePath(file.Path)); - EnsurePathWithinRoot(targetPath, targetDeployment); - if (!File.Exists(targetPath)) - { - throw new FileNotFoundException($"Expected target file was not created: {file.Path}"); - } - - if (TryGetExpectedSha512(file, out var expectedSha512)) - { - var actualSha512 = ComputeSha512(targetPath); - if (!actualSha512.AsSpan().SequenceEqual(expectedSha512)) - { - throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'."); - } - return; - } - - if (!string.IsNullOrWhiteSpace(file.Sha256)) - { - var expectedSha256 = NormalizeHashText(file.Sha256); - var actualSha256 = ComputeSha256Hex(targetPath); - if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'."); - } - } - } - - private string ResolvePlondsObjectPath(PlondsFileEntry file) - { - var candidates = new List(); - AddPlondsPathCandidates(candidates, file.ObjectPath); - AddPlondsPathCandidates(candidates, file.ObjectKey); - AddPlondsPathCandidates(candidates, file.ArchivePath); - AddPlondsPathCandidates(candidates, file.ObjectUrl); - AddPlondsPathCandidates(candidates, file.Url); - - if (TryGetExpectedObjectSha512(file, out var expectedSha512) || TryGetExpectedSha512(file, out expectedSha512)) - { - var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant(); - AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex)); - if (hashHex.Length > 2) - { - AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex[..2], hashHex)); - // Backward compatibility for previously staged paths. - AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..])); - } - AddPlondsPathCandidates(candidates, Path.Combine(PlondsObjectsDirectoryName, $"{hashHex}.gz")); - } - - foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) - { - var fullPath = Path.GetFullPath(Path.Combine(_incomingRoot, relativePath)); - if (!fullPath.StartsWith(Path.GetFullPath(_incomingRoot), StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (File.Exists(fullPath)) - { - return fullPath; - } - } - - throw new FileNotFoundException($"Unable to resolve object payload for '{file.Path}'."); - } - - private static byte[]? TryInflateGzip(byte[] payload) + private void TryDeleteApplyLock() { try { - using var input = new MemoryStream(payload, writable: false); - using var gzip = new GZipStream(input, CompressionMode.Decompress); - using var output = new MemoryStream(); - gzip.CopyTo(output); - return output.ToArray(); - } - catch - { - return null; - } - } - - private void AddPlondsPathCandidates(ICollection candidates, string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return; - } - - var normalized = value.Trim(); - if (Uri.TryCreate(normalized, UriKind.Absolute, out var absoluteUri)) - { - normalized = Uri.UnescapeDataString(absoluteUri.AbsolutePath); - } - - normalized = normalized.TrimStart('/', '\\'); - if (string.IsNullOrWhiteSpace(normalized)) - { - return; - } - - normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); - candidates.Add(normalized); - - if (!normalized.StartsWith($"{PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) - { - candidates.Add(Path.Combine(PlondsObjectsDirectoryName, normalized)); - } - - var fileName = Path.GetFileName(normalized); - if (!string.IsNullOrWhiteSpace(fileName)) - { - candidates.Add(Path.Combine(PlondsObjectsDirectoryName, fileName)); - } - } - - private static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected) - { - expected = []; - if (file.Sha512Bytes is { Length: > 0 }) - { - expected = file.Sha512Bytes; - return true; - } - - if (file.Hash is not null) - { - if (file.Hash.Bytes is { Length: > 0 }) + if (File.Exists(_paths.ApplyLockPath)) { - expected = file.Hash.Bytes; - return true; - } - - if (string.IsNullOrWhiteSpace(file.Hash.Algorithm) || - file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) - { - if (TryParseHashBytes(file.Hash.Value, out expected)) - { - return true; - } - } - } - - if (TryParseHashBytes(file.Sha512, out expected)) - { - return true; - } - - return TryParseHashBytes(file.Sha512Base64, out expected); - } - - private static bool TryGetExpectedObjectSha512(PlondsFileEntry file, out byte[] expected) - { - expected = []; - if (file.Hash is null) - { - return false; - } - - if (file.Hash.Bytes is { Length: > 0 }) - { - expected = file.Hash.Bytes; - return true; - } - - if (!string.IsNullOrWhiteSpace(file.Hash.Algorithm) && - !file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return TryParseHashBytes(file.Hash.Value, out expected); - } - - private static bool TryParseHashBytes(string? rawHash, out byte[] bytes) - { - bytes = []; - if (string.IsNullOrWhiteSpace(rawHash)) - { - return false; - } - - var normalized = rawHash.Trim(); - var separator = normalized.IndexOf(':'); - if (separator >= 0 && separator < normalized.Length - 1) - { - normalized = normalized[(separator + 1)..].Trim(); - } - - var compact = normalized.Replace("-", string.Empty); - if (compact.Length > 0 && compact.Length % 2 == 0 && IsHexString(compact)) - { - try - { - bytes = Convert.FromHexString(compact); - return true; - } - catch - { - return false; - } - } - - try - { - bytes = Convert.FromBase64String(normalized); - return bytes.Length > 0; - } - catch - { - return false; - } - } - - private static bool IsHexString(string value) - { - foreach (var ch in value) - { - if (!Uri.IsHexDigit(ch)) - { - return false; - } - } - - return true; - } - - private static string NormalizeHashText(string hash) - { - var normalized = hash.Trim(); - var separator = normalized.IndexOf(':'); - if (separator >= 0 && separator < normalized.Length - 1) - { - normalized = normalized[(separator + 1)..]; - } - - return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant(); - } - - private static List CollectPlondsFileEntries(PlondsFileMap fileMap) - { - var files = new List(); - if (fileMap.Files is { Count: > 0 }) - { - files.AddRange(fileMap.Files); - } - - if (fileMap.Components is null) - { - return files; - } - - foreach (var component in fileMap.Components) - { - if (component.Files is { Count: > 0 }) - { - files.AddRange(component.Files); - } - } - - return files; - } - - private static void PopulatePlondsManifestFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection files) - { - if (string.IsNullOrWhiteSpace(fileMapJson)) - { - return; - } - - using var document = JsonDocument.Parse(fileMapJson); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object) - { - return; - } - - fileMap.FromVersion ??= ReadJsonStringIgnoreCase(root, "fromversion"); - fileMap.ToVersion ??= ReadJsonStringIgnoreCase(root, "toversion"); - fileMap.Version ??= ReadJsonStringIgnoreCase(root, "version"); - fileMap.Platform ??= ReadJsonStringIgnoreCase(root, "platform"); - fileMap.Arch ??= ReadJsonStringIgnoreCase(root, "arch"); - fileMap.DistributionId ??= ReadJsonStringIgnoreCase(root, "distributionid"); - - if (TryGetJsonPropertyIgnoreCase(root, "metadata", out var metadataNode) && - metadataNode.ValueKind == JsonValueKind.Object) - { - foreach (var property in metadataNode.EnumerateObject()) - { - var key = property.Name; - if (string.IsNullOrWhiteSpace(key)) - { - continue; - } - - var value = property.Value.ValueKind == JsonValueKind.String - ? property.Value.GetString() - : property.Value.ToString(); - if (string.IsNullOrWhiteSpace(value)) - { - continue; - } - - fileMap.Metadata[key] = value; - } - } - - if (TryGetJsonPropertyIgnoreCase(root, "files", out var rootFilesNode)) - { - ParsePlondsFilesNode(rootFilesNode, null, files); - } - - if (!TryGetJsonPropertyIgnoreCase(root, "components", out var componentsNode)) - { - return; - } - - if (componentsNode.ValueKind == JsonValueKind.Object) - { - foreach (var component in componentsNode.EnumerateObject()) - { - if (component.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - if (TryGetJsonPropertyIgnoreCase(component.Value, "files", out var componentFilesNode)) - { - ParsePlondsFilesNode(componentFilesNode, component.Name, files); - } - } - - return; - } - - if (componentsNode.ValueKind != JsonValueKind.Array) - { - return; - } - - foreach (var component in componentsNode.EnumerateArray()) - { - if (component.ValueKind != JsonValueKind.Object) - { - continue; - } - - var componentName = ReadJsonStringIgnoreCase(component, "name"); - if (TryGetJsonPropertyIgnoreCase(component, "files", out var componentFilesNode)) - { - ParsePlondsFilesNode(componentFilesNode, componentName, files); - } - } - } - - private static void ParsePlondsFilesNode(JsonElement filesNode, string? componentName, ICollection files) - { - if (filesNode.ValueKind == JsonValueKind.Object) - { - foreach (var fileEntry in filesNode.EnumerateObject()) - { - if (fileEntry.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - if (TryCreatePlondsFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed)) - { - files.Add(parsed); - } - } - - return; - } - - if (filesNode.ValueKind != JsonValueKind.Array) - { - return; - } - - foreach (var fileEntry in filesNode.EnumerateArray()) - { - if (fileEntry.ValueKind != JsonValueKind.Object) - { - continue; - } - - var fallbackPath = ReadJsonStringIgnoreCase(fileEntry, "path"); - if (TryCreatePlondsFileEntry(fallbackPath, componentName, fileEntry, out var parsed)) - { - files.Add(parsed); - } - } - } - - private static bool TryCreatePlondsFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry) - { - entry = new PlondsFileEntry(); - var path = ReadJsonStringIgnoreCase(node, "path"); - if (string.IsNullOrWhiteSpace(path)) - { - path = fallbackPath; - } - - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } - - var fileSha512 = ReadJsonByteArrayIgnoreCase(node, "filesha512") - ?? ReadJsonByteArrayIgnoreCase(node, "sha512"); - var archiveSha512 = ReadJsonByteArrayIgnoreCase(node, "archivesha512"); - - var fileSha512Text = ReadJsonStringIgnoreCase(node, "filesha512") - ?? ReadJsonStringIgnoreCase(node, "sha512"); - var archiveSha512Text = ReadJsonStringIgnoreCase(node, "archivesha512"); - - var downloadUrl = ReadJsonStringIgnoreCase(node, "archivedownloadurl") - ?? ReadJsonStringIgnoreCase(node, "downloadurl") - ?? ReadJsonStringIgnoreCase(node, "url"); - var objectPath = ReadJsonStringIgnoreCase(node, "objectpath") - ?? ReadJsonStringIgnoreCase(node, "archivepath"); - var objectKey = ReadJsonStringIgnoreCase(node, "objectkey"); - var action = ReadJsonStringIgnoreCase(node, "action"); - - var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(componentName)) - { - metadata["component"] = componentName; - } - - if (TryGetJsonPropertyIgnoreCase(node, "metadata", out var metadataNode) && - metadataNode.ValueKind == JsonValueKind.Object) - { - foreach (var property in metadataNode.EnumerateObject()) - { - if (property.Value.ValueKind == JsonValueKind.Null || - property.Value.ValueKind == JsonValueKind.Undefined) - { - continue; - } - - var value = property.Value.ValueKind == JsonValueKind.String - ? property.Value.GetString() - : property.Value.ToString(); - if (string.IsNullOrWhiteSpace(value)) - { - continue; - } - - metadata[property.Name] = value; - } - } - - entry = new PlondsFileEntry - { - Path = path, - Action = string.IsNullOrWhiteSpace(action) ? "replace" : action, - Url = downloadUrl, - ObjectUrl = ReadJsonStringIgnoreCase(node, "objecturl"), - ObjectPath = objectPath, - ObjectKey = objectKey, - ArchivePath = ReadJsonStringIgnoreCase(node, "archivepath"), - Sha256 = ReadJsonStringIgnoreCase(node, "sha256") ?? ReadJsonStringIgnoreCase(node, "filesha256"), - Sha512 = fileSha512Text, - Sha512Base64 = null, - Sha512Bytes = fileSha512, - Metadata = metadata - }; - - if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text)) - { - entry.Hash = new PlondsHashDescriptor - { - Algorithm = "sha512", - Bytes = archiveSha512, - Value = archiveSha512Text ?? (archiveSha512 is { Length: > 0 } - ? Convert.ToHexString(archiveSha512).ToLowerInvariant() - : null) - }; - } - else if (TryGetJsonPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object) - { - entry.Hash = new PlondsHashDescriptor - { - Algorithm = ReadJsonStringIgnoreCase(hashNode, "algorithm"), - Value = ReadJsonStringIgnoreCase(hashNode, "value"), - Bytes = ReadJsonByteArrayIgnoreCase(hashNode, "bytes") - }; - } - - return true; - } - - private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file) - { - if (OperatingSystem.IsWindows()) - { - return; - } - - if (!file.Metadata.TryGetValue("unixFileMode", out var rawMode) || - string.IsNullOrWhiteSpace(rawMode)) - { - return; - } - - try - { - var normalized = rawMode.Trim(); - var modeValue = Convert.ToInt32(normalized, 8); - File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue); - } - catch - { - // Best-effort only. A bad mode should not break the entire update. - } - } - - private static bool TryGetJsonPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value) - { - if (node.ValueKind == JsonValueKind.Object) - { - foreach (var property in node.EnumerateObject()) - { - if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) - { - value = property.Value; - return true; - } - } - } - - value = default; - return false; - } - - private static string? ReadJsonStringIgnoreCase(JsonElement node, string propertyName) - { - if (!TryGetJsonPropertyIgnoreCase(node, propertyName, out var value)) - { - return null; - } - - return value.ValueKind == JsonValueKind.String - ? value.GetString() - : value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null - ? null - : value.ToString(); - } - - private static byte[]? ReadJsonByteArrayIgnoreCase(JsonElement node, string propertyName) - { - if (!TryGetJsonPropertyIgnoreCase(node, propertyName, out var value)) - { - return null; - } - - return ParseJsonByteArrayValue(value); - } - - private static byte[]? ParseJsonByteArrayValue(JsonElement value) - { - switch (value.ValueKind) - { - case JsonValueKind.Array: - { - var bytes = new byte[value.GetArrayLength()]; - var index = 0; - foreach (var element in value.EnumerateArray()) - { - if (!element.TryGetInt32(out var number) || number < byte.MinValue || number > byte.MaxValue) - { - return null; - } - - bytes[index++] = (byte)number; - } - - return bytes; - } - case JsonValueKind.String: - { - var text = value.GetString(); - return TryParseHashBytes(text, out var parsed) ? parsed : null; - } - default: - return null; - } - } - - private static PlondsUpdateMetadata? LoadPlondsUpdateMetadata(string path) - { - if (!File.Exists(path)) - { - return null; - } - - try - { - var text = File.ReadAllText(path); - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - return JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata); - } - catch - { - return null; - } - } - - private static string? ResolvePlondsSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata) - { - return FirstNonEmpty( - metadata?.FromVersion, - fileMap.FromVersion, - TryGetMetadataValue(fileMap.Metadata, "fromVersion"), - TryGetMetadataValue(fileMap.Metadata, "sourceVersion")); - } - - private static string? ResolvePlondsTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata) - { - return FirstNonEmpty( - metadata?.ToVersion, - fileMap.ToVersion, - fileMap.Version, - TryGetMetadataValue(fileMap.Metadata, "toVersion"), - TryGetMetadataValue(fileMap.Metadata, "targetVersion")); - } - - private static string? TryGetMetadataValue(Dictionary? metadata, string key) - { - if (metadata is null || metadata.Count == 0) - { - return null; - } - - foreach (var pair in metadata) - { - if (!string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!string.IsNullOrWhiteSpace(pair.Value)) - { - return pair.Value; - } - } - - return null; - } - - private static string? FirstNonEmpty(params string?[] values) - { - foreach (var value in values) - { - if (!string.IsNullOrWhiteSpace(value)) - { - return value; - } - } - - return null; - } - - /// - /// 闁稿繈鍔嶉弻濠勨偓鐟邦槼椤ュ﹪宕烽悜妯荤彲闁挎稒姘ㄥú鍧楀箳閵夈儳瀹夐柣顫妽濞插潡寮弶鍨樁濞达絾绮堢拹鐔革純閺嶎煈鍋ч梺顔哄妿鐠? - /// - private async Task ApplyInitialDeploymentAsync( - SignedFileMap fileMap, - string archivePath, - string fileMapPath, - string signaturePath) - { - var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? "1.0.0" : fileMap.ToVersion!; - var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion); - var partialMarker = Path.Combine(targetDeployment, ".partial"); - var snapshotPath = Path.Combine(_snapshotsRoot, $"initial-{Guid.NewGuid():N}.json"); - - var extractRoot = Path.Combine(_incomingRoot, "extracted"); - try - { - // Save a snapshot for diagnostics and future rollback consistency. - var snapshot = new SnapshotMetadata - { - SnapshotId = Guid.NewGuid().ToString("N"), - SourceVersion = "0.0.0", - TargetVersion = targetVersion, - CreatedAt = DateTimeOffset.UtcNow, - SourceDirectory = "", - TargetDirectory = targetDeployment, - Status = "pending" - }; - SaveSnapshot(snapshotPath, snapshot); - - // 婵炴挸鎳愰幃濠囩嵁閹澏鎺楀储鐎n偅绾柡鍌涙緲鐎? - 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); - - // Apply all files from the extracted payload into the first deployment directory. - foreach (var file in fileMap.Files) - { - ApplyInitialFileEntry(file, 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}'."); - } - } - - // Mark the deployment as current and remove the partial marker. - var currentMarker = Path.Combine(targetDeployment, ".current"); - File.WriteAllText(currentMarker, string.Empty); - if (File.Exists(partialMarker)) - { - File.Delete(partialMarker); - } - - // 婵炴挸鎳愰幃濠囧即鐎涙ɑ鐓€闁? snapshot.Status = "applied"; - SaveSnapshot(snapshotPath, snapshot); - CleanupIncomingArtifacts(); - - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "ok", - Message = $"Initial deployment to {targetVersion}.", - CurrentVersion = "0.0.0", - TargetVersion = targetVersion - }; - } - catch (Exception ex) - { - // Clean up the failed target deployment before returning the error result. - try - { - if (Directory.Exists(targetDeployment)) - { - Directory.Delete(targetDeployment, true); - } - } - catch - { - } - - return new LauncherResult - { - Success = false, - Stage = "update.apply", - Code = "initial_deploy_failed", - Message = "Failed to apply initial deployment.", - ErrorMessage = ex.Message, - CurrentVersion = "0.0.0", - TargetVersion = targetVersion - }; - } - finally - { - try - { - if (Directory.Exists(extractRoot)) - { - Directory.Delete(extractRoot, true); - } - } - catch - { - } - } - } - - /// - /// 閹煎瓨姊婚弫銈夊礆濠靛棭娼楅梺顔哄妿鐠佹煡寮崶锔筋偨闁挎稑鐗嗛崣蹇涘棘閺夎法鏆旈悷浣告噹濠р偓闁哄拋鍨界槐婵囩▔瀹ュ浠橀悷鏇氱劍缁噣鎯勯鑲╃Э闁? /// - private void ApplyInitialFileEntry(UpdateFileEntry file, 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); - } - - // 闁哄啰濮鹃鎴﹀及?add 閺夆晜蓱濡?replace闁挎稑鐭傞崗妯荤鎼粹€崇缂傚倵鏅涚€垫ɑ寰勫鍛厬 - 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); - } - - 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), AppJsonContext.Default.SnapshotMetadata); - if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory)) - { - return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata."); - } - - if (!Directory.Exists(snapshot.SourceDirectory)) - { - return Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}"); - } - - 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() - { - RetainDeploymentsForRollback(); - } - - 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 toDestroy = Path.Combine(toDeployment, ".destroy"); - var toPartial = Path.Combine(toDeployment, ".partial"); - - File.WriteAllText(toCurrent, string.Empty); - if (File.Exists(toDestroy)) - { - File.Delete(toDestroy); - } - - if (File.Exists(fromCurrent)) - { - File.Delete(fromCurrent); - } - - File.WriteAllText(fromDestroy, string.Empty); - if (File.Exists(toPartial)) - { - File.Delete(toPartial); - } - } - - private RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot) - { - try - { - if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory)) - { - Directory.Delete(snapshot.TargetDirectory, true); - } - - if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory) || !Directory.Exists(snapshot.SourceDirectory)) - { - return new RollbackAttemptResult(false, "Source deployment is missing."); - } - - 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); - } - - return new RollbackAttemptResult(true, null); - } - catch (Exception ex) - { - return new RollbackAttemptResult(false, ex.Message); - } - } - - private void RetainDeploymentsForRollback() - { - _deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); - } - - private sealed record RollbackAttemptResult(bool Success, string? ErrorMessage); - - public void CleanupIncomingArtifacts() - { - foreach (var path in new[] - { - Path.Combine(_incomingRoot, SignedFileMapName), - Path.Combine(_incomingRoot, SignatureFileName), - Path.Combine(_incomingRoot, ArchiveFileName), - Path.Combine(_incomingRoot, PlondsFileMapName), - Path.Combine(_incomingRoot, PlondsSignatureFileName), - Path.Combine(_incomingRoot, PlondsUpdateMetadataName), - _installCheckpointPath - }) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - catch - { - } - } - - foreach (var directory in new[] - { - Path.Combine(_incomingRoot, PlondsObjectsDirectoryName) - }) - { - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, true); - } - } - catch - { - } - } - } - - private (bool Success, string Message) VerifySignature(string payloadPath, string signaturePath, string signatureName) - { - if (!File.Exists(signaturePath)) - { - return (false, $"Missing {signatureName}."); - } - - var publicKeyPath = Path.Combine(_launcherRoot, UpdateDirectoryName, PublicKeyFileName); - if (!File.Exists(publicKeyPath)) - { - return (false, $"Missing public key: {publicKeyPath}"); - } - - var payloadBytes = File.ReadAllBytes(payloadPath); - 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(payloadBytes, 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 byte[] ComputeSha512(string filePath) - { - using var stream = File.OpenRead(filePath); - return SHA512.HashData(stream); - } - - private static void SaveSnapshot(string path, SnapshotMetadata snapshot) - { - File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); - } - - private InstallCheckpoint? LoadInstallCheckpoint() - { - if (!File.Exists(_installCheckpointPath)) - { - return null; - } - - try - { - var text = File.ReadAllText(_installCheckpointPath); - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint); - } - catch - { - return null; - } - } - - private void SaveInstallCheckpoint(InstallCheckpoint checkpoint) - { - File.WriteAllText(_installCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); - } - - private void DeleteInstallCheckpoint() - { - try - { - if (File.Exists(_installCheckpointPath)) - { - File.Delete(_installCheckpointPath); + File.Delete(_paths.ApplyLockPath); } } catch { } } - - 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/Update/UpdateEngineFactory.cs b/LanMountainDesktop.Launcher/Update/UpdateEngineFactory.cs new file mode 100644 index 0000000..5745f05 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/UpdateEngineFactory.cs @@ -0,0 +1,7 @@ +namespace LanMountainDesktop.Launcher.Update; + +internal static class UpdateEngineFactory +{ + public static IUpdateEngine Create(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) => + new UpdateEngineFacade(deploymentLocator, progressReporter); +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs b/LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs new file mode 100644 index 0000000..6096855 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs @@ -0,0 +1,68 @@ +using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class UpdateEnginePaths +{ + public const string UpdateDirectoryName = "update"; + public const string IncomingDirectoryName = "incoming"; + public const string SnapshotsDirectoryName = "snapshots"; + public const string SignedFileMapName = "files.json"; + public const string SignatureFileName = "files.json.sig"; + public const string ArchiveFileName = "update.zip"; + public const string PlondsFileMapName = "plonds-filemap.json"; + public const string PlondsSignatureFileName = "plonds-filemap.sig"; + public const string PlondsUpdateMetadataName = "plonds-update.json"; + public const string PlondsObjectsDirectoryName = "objects"; + public const string PublicKeyFileName = "public-key.pem"; + + public UpdateEnginePaths(string appRoot) + { + AppRoot = appRoot; + var resolver = new DataLocationResolver(appRoot); + LauncherRoot = resolver.ResolveLauncherDataPath(); + IncomingRoot = Path.Combine(LauncherRoot, UpdateDirectoryName, IncomingDirectoryName); + SnapshotsRoot = Path.Combine(LauncherRoot, SnapshotsDirectoryName); + InstallCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(appRoot); + } + + public string AppRoot { get; } + + public string LauncherRoot { get; } + + public string IncomingRoot { get; } + + public string SnapshotsRoot { get; } + + public string InstallCheckpointPath { get; } + + public string ApplyLockPath => ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(AppRoot); + + public string DeploymentLockPath => ContractsUpdate.UpdatePaths.GetDeploymentLockPath(AppRoot); + + public string DownloadMarkerPath => ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(AppRoot); + + public string FileMapPath => Path.Combine(IncomingRoot, SignedFileMapName); + + public string SignaturePath => Path.Combine(IncomingRoot, SignatureFileName); + + public string ArchivePath => Path.Combine(IncomingRoot, ArchiveFileName); + + public string PlondsFileMapPath => Path.Combine(IncomingRoot, PlondsFileMapName); + + public string PlondsSignaturePath => Path.Combine(IncomingRoot, PlondsSignatureFileName); + + public string PlondsUpdateMetadataPath => Path.Combine(IncomingRoot, PlondsUpdateMetadataName); + + public string PlondsObjectsRoot => Path.Combine(IncomingRoot, PlondsObjectsDirectoryName); + + public string PublicKeyPath => Path.Combine(LauncherRoot, UpdateDirectoryName, PublicKeyFileName); + + public string ExtractRoot => Path.Combine(IncomingRoot, "extracted"); + + public bool HasPlondsPayload => File.Exists(PlondsFileMapPath) && File.Exists(PlondsSignaturePath); + + public bool HasLegacyPayload => File.Exists(FileMapPath) && File.Exists(ArchivePath); + + public string GetSnapshotPath(string snapshotId) => Path.Combine(SnapshotsRoot, $"{snapshotId}.json"); +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs b/LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs new file mode 100644 index 0000000..a919e61 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs @@ -0,0 +1,18 @@ +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal static class UpdateEngineResults +{ + public 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/Update/UpdateHash.cs b/LanMountainDesktop.Launcher/Update/UpdateHash.cs new file mode 100644 index 0000000..134ba78 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/UpdateHash.cs @@ -0,0 +1,84 @@ +using System.Security.Cryptography; + +namespace LanMountainDesktop.Launcher.Update; + +internal static class UpdateHash +{ + public static string ComputeSha256Hex(string filePath) + { + using var stream = File.OpenRead(filePath); + var hash = SHA256.HashData(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static byte[] ComputeSha512(string filePath) + { + using var stream = File.OpenRead(filePath); + return SHA512.HashData(stream); + } + + public static bool TryParseHashBytes(string? rawHash, out byte[] bytes) + { + bytes = []; + if (string.IsNullOrWhiteSpace(rawHash)) + { + return false; + } + + var normalized = rawHash.Trim(); + var separator = normalized.IndexOf(':'); + if (separator >= 0 && separator < normalized.Length - 1) + { + normalized = normalized[(separator + 1)..].Trim(); + } + + var compact = normalized.Replace("-", string.Empty); + if (compact.Length > 0 && compact.Length % 2 == 0 && IsHexString(compact)) + { + try + { + bytes = Convert.FromHexString(compact); + return true; + } + catch + { + return false; + } + } + + try + { + bytes = Convert.FromBase64String(normalized); + return bytes.Length > 0; + } + catch + { + return false; + } + } + + public static string NormalizeHashText(string hash) + { + var normalized = hash.Trim(); + var separator = normalized.IndexOf(':'); + if (separator >= 0 && separator < normalized.Length - 1) + { + normalized = normalized[(separator + 1)..]; + } + + return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant(); + } + + private static bool IsHexString(string value) + { + foreach (var ch in value) + { + if (!Uri.IsHexDigit(ch)) + { + return false; + } + } + + return true; + } +} diff --git a/LanMountainDesktop.Launcher/Update/UpdatePathGuard.cs b/LanMountainDesktop.Launcher/Update/UpdatePathGuard.cs new file mode 100644 index 0000000..2750489 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/UpdatePathGuard.cs @@ -0,0 +1,20 @@ +namespace LanMountainDesktop.Launcher.Update; + +internal static class UpdatePathGuard +{ + public static string NormalizeRelativePath(string path) + { + var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + return normalized.TrimStart(Path.DirectorySeparatorChar); + } + + public 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}"); + } + } +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateSignatureVerifier.cs b/LanMountainDesktop.Launcher/Update/UpdateSignatureVerifier.cs new file mode 100644 index 0000000..65b919c --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/UpdateSignatureVerifier.cs @@ -0,0 +1,41 @@ +using System.Security.Cryptography; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class UpdateSignatureVerifier(UpdateEnginePaths paths) +{ + public (bool Success, string Message) Verify(string payloadPath, string signaturePath, string signatureName) + { + if (!File.Exists(signaturePath)) + { + return (false, $"Missing {signatureName}."); + } + + if (!File.Exists(paths.PublicKeyPath)) + { + return (false, $"Missing public key: {paths.PublicKeyPath}"); + } + + var payloadBytes = File.ReadAllBytes(payloadPath); + 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(paths.PublicKeyPath)); + var isValid = rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return isValid ? (true, "ok") : (false, "Signature verification failed."); + } +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateSnapshotStore.cs b/LanMountainDesktop.Launcher/Update/UpdateSnapshotStore.cs new file mode 100644 index 0000000..a58e285 --- /dev/null +++ b/LanMountainDesktop.Launcher/Update/UpdateSnapshotStore.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Update; + +internal sealed class UpdateSnapshotStore(UpdateEnginePaths paths) +{ + public string CreateSnapshotPath(string snapshotId) => paths.GetSnapshotPath(snapshotId); + + public void Save(string path, SnapshotMetadata snapshot) + { + File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); + } + + public (string Path, SnapshotMetadata Snapshot)? LoadLatest() + { + if (!Directory.Exists(paths.SnapshotsRoot)) + { + return null; + } + + var snapshotPath = Directory + .EnumerateFiles(paths.SnapshotsRoot, "*.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(File.GetCreationTimeUtc) + .FirstOrDefault(); + if (string.IsNullOrWhiteSpace(snapshotPath)) + { + return null; + } + + var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata); + return snapshot is null ? null : (snapshotPath, snapshot); + } +} diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs index d98adee..8ef9ea1 100644 --- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs @@ -8,7 +8,7 @@ using Avalonia.Media; using Avalonia.Threading; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Infrastructure; +using LanMountainDesktop.Launcher.Shell; namespace LanMountainDesktop.Launcher.Views; diff --git a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs index a67f607..18f61b3 100644 --- a/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs @@ -7,7 +7,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Threading; using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Infrastructure; +using LanMountainDesktop.Launcher.Shell; namespace LanMountainDesktop.Launcher.Views; diff --git a/LanMountainDesktop.Tests/LauncherArchitectureTests.cs b/LanMountainDesktop.Tests/LauncherArchitectureTests.cs index ad1f4c2..ea559cb 100644 --- a/LanMountainDesktop.Tests/LauncherArchitectureTests.cs +++ b/LanMountainDesktop.Tests/LauncherArchitectureTests.cs @@ -1,23 +1,22 @@ -using System.Reflection; using Xunit; namespace LanMountainDesktop.Tests; public sealed class LauncherArchitectureTests { - private static readonly string LauncherAssemblyName = "LanMountainDesktop.Launcher"; - [Fact] - public void Deployment_Update_Startup_Infrastructure_DoNotReferenceAvalonia() + public void CoreLauncherFolders_DoNotUseAvaloniaNamespaces() { var forbidden = new[] { "Deployment", "Update", "Startup", "Infrastructure" }; - foreach (var nsSuffix in forbidden) + foreach (var folder in forbidden.Select(folder => Path.Combine(LauncherProjectRoot, folder))) { - var types = GetLauncherTypes($"LanMountainDesktop.Launcher.{nsSuffix}"); - var assembly = types.First().Assembly; - Assert.DoesNotContain( - assembly.GetReferencedAssemblies(), - a => string.Equals(a.Name, "Avalonia", StringComparison.OrdinalIgnoreCase)); + var offenders = Directory + .EnumerateFiles(folder, "*.cs", SearchOption.AllDirectories) + .Where(file => File.ReadAllText(file).Contains("using Avalonia", StringComparison.Ordinal)) + .Select(RelativeToRepo) + .ToArray(); + + Assert.Empty(offenders); } } @@ -29,13 +28,59 @@ public sealed class LauncherArchitectureTests Assert.Null(coordinator); } - private static IEnumerable GetLauncherTypes(string namespacePrefix) + [Fact] + public void CliAndShellEntryHandlers_DoNotDependOnConcreteUpdateEngineFacade() { - var assembly = AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => string.Equals(a.GetName().Name, LauncherAssemblyName, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException("Launcher assembly not loaded."); + var guardedFiles = new[] + { + Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs"), + Path.Combine(LauncherProjectRoot, "Shell", "ApplyUpdateGuiFlow.cs") + }.Concat(Directory.EnumerateFiles( + Path.Combine(LauncherProjectRoot, "Shell", "EntryHandlers"), + "*.cs", + SearchOption.AllDirectories)); - return assembly.GetTypes() - .Where(t => t.Namespace is not null && t.Namespace.StartsWith(namespacePrefix, StringComparison.Ordinal)); + var offenders = guardedFiles + .Where(file => File.ReadAllText(file).Contains("UpdateEngineFacade", StringComparison.Ordinal)) + .Select(RelativeToRepo) + .ToArray(); + + Assert.Empty(offenders); } + + [Fact] + public void LauncherFacadeAndCompositionRootStayThin() + { + AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Update", "UpdateEngineFacade.cs"), 140); + AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Shell", "LauncherCompositionRoot.cs"), 80); + } + + private static string LauncherProjectRoot => Path.Combine(RepoRoot, "LanMountainDesktop.Launcher"); + + private static string RepoRoot + { + get + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "LanMountainDesktop.slnx"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Unable to locate repository root."); + } + } + + private static void AssertFileLineCountAtMost(string path, int maxLines) + { + var lineCount = File.ReadLines(path).Count(); + Assert.True(lineCount <= maxLines, $"{RelativeToRepo(path)} has {lineCount} lines; expected <= {maxLines}."); + } + + private static string RelativeToRepo(string path) => Path.GetRelativePath(RepoRoot, path); } diff --git a/LanMountainDesktop.Tests/UpdateStrategyTests.cs b/LanMountainDesktop.Tests/UpdateStrategyTests.cs new file mode 100644 index 0000000..b1e0722 --- /dev/null +++ b/LanMountainDesktop.Tests/UpdateStrategyTests.cs @@ -0,0 +1,250 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using LanMountainDesktop.Launcher; +using LanMountainDesktop.Launcher.Models; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class PendingUpdateDetectorTests : IDisposable +{ + private readonly TempLauncherRoot _root = new(); + + [Fact] + public void ValidateIncomingState_WhenNoPayloadButDeploymentLockExists_ReturnsNoop() + { + _root.WriteDeploymentLock(); + var detector = new PendingUpdateDetector( + new DeploymentLocator(_root.AppRoot), + _root.Paths, + new UpdateSignatureVerifier(_root.Paths)); + + var result = detector.ValidateIncomingState(); + + Assert.True(result.Success); + Assert.Equal("noop", result.Code); + } + + public void Dispose() => _root.Dispose(); +} + +public sealed class UpdateSignatureVerifierTests : IDisposable +{ + private readonly TempLauncherRoot _root = new(); + + [Fact] + public void Verify_WhenSignatureIsMissing_ReturnsStructuredFailure() + { + var payload = Path.Combine(_root.Paths.IncomingRoot, "files.json"); + Directory.CreateDirectory(_root.Paths.IncomingRoot); + File.WriteAllText(payload, "{}"); + + var result = new UpdateSignatureVerifier(_root.Paths) + .Verify(payload, Path.Combine(_root.Paths.IncomingRoot, "files.json.sig"), "files.json.sig"); + + Assert.False(result.Success); + Assert.Equal("Missing files.json.sig.", result.Message); + } + + public void Dispose() => _root.Dispose(); +} + +public sealed class IncomingArtifactsCleanerTests : IDisposable +{ + private readonly TempLauncherRoot _root = new(); + + [Fact] + public void Cleanup_RemovesLegacyPlondsAndCheckpointArtifacts() + { + Directory.CreateDirectory(_root.Paths.PlondsObjectsRoot); + foreach (var path in new[] + { + _root.Paths.FileMapPath, + _root.Paths.SignaturePath, + _root.Paths.ArchivePath, + _root.Paths.PlondsFileMapPath, + _root.Paths.PlondsSignaturePath, + _root.Paths.PlondsUpdateMetadataPath, + _root.Paths.InstallCheckpointPath, + Path.Combine(_root.Paths.PlondsObjectsRoot, "payload") + }) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "x"); + } + + new IncomingArtifactsCleaner(_root.Paths).Cleanup(); + + Assert.False(File.Exists(_root.Paths.FileMapPath)); + Assert.False(File.Exists(_root.Paths.InstallCheckpointPath)); + Assert.False(Directory.Exists(_root.Paths.PlondsObjectsRoot)); + } + + public void Dispose() => _root.Dispose(); +} + +public sealed class DeploymentActivatorTests : IDisposable +{ + private readonly TempLauncherRoot _root = new(); + + [Fact] + public void Activate_MovesCurrentMarkerAndMarksPreviousDestroy() + { + var from = Path.Combine(_root.AppRoot, "app-1"); + var to = Path.Combine(_root.AppRoot, "app-2"); + Directory.CreateDirectory(from); + Directory.CreateDirectory(to); + File.WriteAllText(Path.Combine(from, ".current"), string.Empty); + File.WriteAllText(Path.Combine(to, ".partial"), string.Empty); + + new DeploymentActivator(new DeploymentLocator(_root.AppRoot)).Activate(from, to); + + Assert.False(File.Exists(Path.Combine(from, ".current"))); + Assert.True(File.Exists(Path.Combine(from, ".destroy"))); + Assert.True(File.Exists(Path.Combine(to, ".current"))); + Assert.False(File.Exists(Path.Combine(to, ".partial"))); + } + + public void Dispose() => _root.Dispose(); +} + +public sealed class RollbackStrategyTests : IDisposable +{ + private readonly TempLauncherRoot _root = new(); + + [Fact] + public void RollbackLatest_WhenNoSnapshotsExist_ReturnsNoSnapshot() + { + var snapshotStore = new UpdateSnapshotStore(_root.Paths); + var activator = new DeploymentActivator(new DeploymentLocator(_root.AppRoot)); + + var result = new RollbackStrategy(new DeploymentLocator(_root.AppRoot), snapshotStore, activator) + .RollbackLatest(); + + Assert.False(result.Success); + Assert.Equal("no_snapshot", result.Code); + } + + public void Dispose() => _root.Dispose(); +} + +public sealed class PlondsUpdateApplierTests +{ + [Fact] + public void ManifestParser_ReadsObjectComponentFiles() + { + var map = new PlondsFileMap(); + var entries = PlondsManifestParser.CollectFileEntries(map); + + PlondsManifestParser.PopulateFromRawJson( + """ + { + "toVersion": "2.0.0", + "components": { + "desktop": { + "files": { + "LanMountainDesktop.exe": { + "archiveSha512": "abcd", + "archivePath": "objects/ab/cd" + } + } + } + } + } + """, + map, + entries); + + Assert.Equal("2.0.0", PlondsManifestParser.ResolveTargetVersion(map, null)); + var entry = Assert.Single(entries); + Assert.Equal("LanMountainDesktop.exe", entry.Path); + Assert.Equal("desktop", entry.Metadata["component"]); + } +} + +public sealed class LegacyUpdateApplierTests : IDisposable +{ + private readonly TempLauncherRoot _root = new(); + + [Fact] + public async Task ApplyAsync_WhenSignatureIsMissing_ReturnsSignatureFailure() + { + _root.WriteDeploymentLock(); + Directory.CreateDirectory(_root.Paths.IncomingRoot); + File.WriteAllText(_root.Paths.FileMapPath, JsonSerializer.Serialize(new SignedFileMap + { + FromVersion = "1.0.0", + ToVersion = "2.0.0", + Files = [new UpdateFileEntry { Path = "state.txt" }] + }, AppJsonContext.Default.SignedFileMap)); + using (var archive = ZipFile.Open(_root.Paths.ArchivePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("state.txt"); + await using var stream = entry.Open(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("state")); + } + + var applier = CreateLegacyApplier(); + var result = await applier.ApplyAsync(); + + Assert.False(result.Success); + Assert.Equal("signature_failed", result.Code); + } + + public void Dispose() => _root.Dispose(); + + private LegacyUpdateApplier CreateLegacyApplier() + { + var locator = new DeploymentLocator(_root.AppRoot); + var snapshotStore = new UpdateSnapshotStore(_root.Paths); + var checkpointStore = new InstallCheckpointStore(_root.Paths); + var activator = new DeploymentActivator(locator); + var cleaner = new IncomingArtifactsCleaner(_root.Paths); + return new LegacyUpdateApplier( + locator, + _root.Paths, + new UpdateSignatureVerifier(_root.Paths), + new NullUpdateProgressReporter(), + snapshotStore, + checkpointStore, + activator, + cleaner); + } +} + +internal sealed class TempLauncherRoot : IDisposable +{ + public TempLauncherRoot() + { + AppRoot = Path.Combine(Path.GetTempPath(), "lmd-launcher-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(AppRoot); + Paths = new UpdateEnginePaths(AppRoot); + Directory.CreateDirectory(Paths.IncomingRoot); + } + + public string AppRoot { get; } + + public UpdateEnginePaths Paths { get; } + + public void WriteDeploymentLock() + { + Directory.CreateDirectory(Path.GetDirectoryName(Paths.DeploymentLockPath)!); + File.WriteAllText(Paths.DeploymentLockPath, string.Empty); + } + + public void Dispose() + { + try + { + if (Directory.Exists(AppRoot)) + { + Directory.Delete(AppRoot, true); + } + } + catch + { + } + } +} diff --git a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs index ff16e9d..e5927c2 100644 --- a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs +++ b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs @@ -130,7 +130,7 @@ public sealed class WindowLayerIsolationTests { var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs"); var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs"); - var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "Services", "AirApp", "IAirAppProcessStarter.cs"); + var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "AirApp", "IAirAppProcessStarter.cs"); var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs"); Assert.Contains("DataRoot", optionsSource); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4178d2c..03906a5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,7 +26,7 @@ 2. Launcher 扫描 `app-*` 目录,选择最佳版本 (优先 `.current` 标记,然后按版本号降序) 3. 首次启动显示 OOBE 引导 (`OobeWindow`) 4. 显示 Splash 启动动画 (`SplashWindow`) -5. 检查并应用待处理的更新 (`UpdateEngineService.ApplyPendingUpdate`) +5. 检查并应用待处理的更新 (`IUpdateEngine.ApplyPendingUpdateAsync` / `UpdateEngineFacade`) 6. 启动主程序 `app-{version}/LanMountainDesktop.exe`(待处理插件安装/升级由 Host 在 `PluginRuntimeService.ApplyPendingPluginOperations()` 中应用,而非 Launcher 启动流程) 7. 清理标记为 `.destroy` 的旧版本 @@ -97,8 +97,8 @@ | 服务 | 职责 | |------|------| | `DeploymentLocator` | 扫描和定位 `app-*` 版本目录,选择最佳版本 | -| `UpdateEngineService` | 下载、验证、应用增量更新,支持原子化更新和回滚 | -| `LauncherFlowCoordinator` | 协调 OOBE → Splash → 更新 → 启动主程序的完整流程 | +| `IUpdateEngine` / `UpdateEngineFacade` | 更新门面;pending 检测、签名、Legacy/PLONDS apply、回滚、清理委托给 `Update/` 策略类 | +| `LauncherOrchestrator` / `LaunchPipeline` | 协调 OOBE → Splash → 更新 → 启动主程序的完整流程 | | `OobeStateService` | 管理首次运行状态 | | `PluginInstallerService` | CLI 维护:`plugin install` 直接安装 `.laapp` | | `PluginUpgradeQueueService` | CLI 维护:`plugin update` 应用待处理队列(正常市场安装/升级由 Host 处理) | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5fd2c0c..2cd18f4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -64,9 +64,9 @@ dotnet test LanMountainDesktop.slnx -c Debug ### 调试建议 -- **Launcher 启动问题优先看 `LanMountainDesktop.Launcher/Program.cs` 和 `Services/LauncherFlowCoordinator.cs`** -- **版本管理问题优先看 `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs`** -- **更新系统问题优先看 `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs` 和 `UpdateCheckService.cs`** +- **Launcher 启动问题优先看 `LanMountainDesktop.Launcher/Program.cs`、`Shell/LauncherOrchestrator.cs` 和 `Startup/LaunchPipeline.cs`** +- **版本管理问题优先看 `LanMountainDesktop.Launcher/Deployment/DeploymentLocator.cs`** +- **更新系统问题优先看 `LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs`、`Update/LegacyUpdateApplier.cs`、`Update/PlondsUpdateApplier.cs` 和 `UpdateCheckService.cs`** - 启动问题优先看 `LanMountainDesktop/Program.cs` 和 `LanMountainDesktop/App.axaml.cs` - 设置窗口和设置页问题优先看 `LanMountainDesktop/Views/`、`ViewModels/` 与相关 `Services/` - 插件加载与安装问题优先看 `LanMountainDesktop/plugins/` diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index 9bad949..ffe2a5d 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -142,7 +142,7 @@ Task CheckForUpdateAsync( - `Preview` - 检查所有版本 (包括 `prerelease=true`) ### IUpdateEngine / UpdateEngineFacade -**职责**: 下载、验证、应用更新(实现位于 `Update/UpdateEngineFacade.cs`,契约 `Update/IUpdateEngine.cs`) +**职责**: `UpdateEngineFacade` 是 `IUpdateEngine` 薄门面;pending 检测、签名、Legacy/PLONDS apply、快照、checkpoint、回滚和清理分别位于 `Update/` 策略/基础设施类。 **关键方法**: ```csharp @@ -325,69 +325,38 @@ static async Task Main(string[] args) } ``` -**App.axaml.cs**: +**App.axaml.cs / Shell 入口**: ```csharp public override void OnFrameworkInitializationCompleted() { - var appRoot = Commands.ResolveAppRoot(context); - var deploymentLocator = new DeploymentLocator(appRoot); - var updateCheckService = new UpdateCheckService("owner", "repo"); - - var coordinator = new LauncherFlowCoordinator( - context, - deploymentLocator, - new OobeStateService(appRoot), - new UpdateEngineService(deploymentLocator), - updateCheckService, - new PluginInstallerService()); - - _ = RunCoordinatorAsync(desktop, coordinator); + var splashWindow = LaunchEntryHandler.CreateSplashWindow(); + splashWindow.Show(); + _ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow); } ``` -**LauncherFlowCoordinator.RunAsync()**: +**LaunchPipeline**: ```csharp -public async Task RunAsync() +internal sealed class LaunchPipeline { - // 1. 清理旧版本 - _deploymentLocator.CleanupDestroyedDeployments(); - - // 2. OOBE - if (_oobeStateService.IsFirstRun()) + public async Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) { - foreach (var step in _oobeSteps) - await step.RunAsync(CancellationToken.None); - } - - // 3. Splash - var splashWindow = await Dispatcher.UIThread.InvokeAsync(() => - { - var window = new SplashWindow(); - window.Show(); - return window; - }); - - try - { - // 4. 应用更新 - var updateResult = await _updateEngine.ApplyPendingUpdateAsync(); - if (!updateResult.Success) - Logger.Warn("Update apply failed, will try to launch existing version."); + foreach (var phase in _phases) + { + var result = await phase.ExecuteAsync(context, cancellationToken); + if (result.Status == LaunchPhaseStatus.Completed) + { + return result.Result!; + } + } - // 5. 启动主程序(插件 pending 由 Host 应用,不在 Launcher 启动步骤处理) - var hostResult = await LaunchHostWithIpcAsync(); - if (!hostResult.Success) - return hostResult; - - return new LauncherResult { Success = true }; - } - finally - { - await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close()); + return LaunchResultBuilder.BuildFailure("launch", "pipeline_incomplete", "Launch pipeline finished without producing a result."); } } ``` +`LauncherFlowCoordinator` 已删除。GUI 顶层生命周期由 `LauncherGuiCoordinator` 处理,启动阶段由 `LaunchPipeline` 和各 `ILaunchPhase` 承载;更新 apply 通过 `IUpdateEngine` 门面进入 `Update/` 策略类。 + ## 命令行接口 ### launch - 启动应用 @@ -505,7 +474,7 @@ public class MyOobeStep : IOobeStep } ``` -2. 在 `LauncherFlowCoordinator` 中注册: +2. 在 `Shell/LauncherOrchestrator.cs` 的 OOBE step 组装处注册,或通过后续 `ILaunchPhase`/OOBE 装配点接入: ```csharp _oobeSteps = [ new WelcomeOobeStep(_oobeStateService), diff --git a/docs/Launcher 单项目内部解耦改造计划(执行版).md b/docs/Launcher 单项目内部解耦改造计划(执行版).md new file mode 100644 index 0000000..819da16 --- /dev/null +++ b/docs/Launcher 单项目内部解耦改造计划(执行版).md @@ -0,0 +1,433 @@ +--- +name: Launcher 单项目解耦 +overview: 在保持单一 LanMountainDesktop.Launcher 项目、单一 exe、零部署风险的前提下,按职责域增量重构:目录分层、RunAsync→Pipeline+Phase、UpdateEngine→策略类、App→纯 Avalonia+LauncherOrchestrator;执行过程中由 Agent 自主 Git 提交,每域可编译可测。 +todos: + - id: phase-a-diagnostics + content: Phase A:Startup 诊断 + HostStartupMonitor 独立类 + AOT 启动检测竞态修复 + 测试 + status: completed + - id: phase-b-directory + content: Phase B1:职责域目录迁移(Deployment/Update/Startup/Oobe/Plugins/Infrastructure),零逻辑变更,提交 + status: completed + - id: phase-b-pipeline + content: Phase B2:RunAsync→LaunchPipeline+ILaunchPhase,引入 LauncherOrchestrator,删除 LauncherFlowCoordinator,提交 + status: completed + - id: phase-b-app-slim + content: Phase B3:App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator,提交 + status: completed + - id: phase-c-di + content: Phase C:LauncherServiceRegistration + 轻量 MS DI,统一 CLI/GUI 装配,提交 + status: completed + - id: phase-d-update-split + content: Phase D:UpdateEngineFacade→门面+策略类(Verifier/Activator/Rollback 等),提交 + status: completed + - id: phase-e-guardrails + content: Phase E:LauncherArchitectureTests + 文档 + AOT 回归,提交 + status: completed +isProject: false +--- + +# Launcher 单项目内部解耦改造计划(执行版) + +## 0. 硬性约束 + + +| 约束 | 说明 | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **单项目** | 仅 `[LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj](LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj)`,不新建 Launcher.* 独立程序集 | +| **单 exe** | 仍只发布 `LanMountainDesktop.Launcher.exe`(AOT 单文件) | +| **零部署风险** | 不改变安装包目录结构、不引入新进程、不改变 Public IPC / Coordinator IPC 拓扑与契约 | +| **增量重构** | 一个职责域一域推进,每步 `dotnet build` + 相关 `dotnet test` 通过后再进下一步 | +| **单进程性能** | 模块间仅 in-process 接口调用,不为解耦新增 IPC | +| **未来可拆** | 各域暴露 `I`* 接口,将来若需多进程可直接复用契约 | +| **Git 自主提交** | Agent 在每个职责域完成且验证通过后 **自动 commit**,无需用户手动提交(见 §8) | + + +外部共享库 `[LanMountainDesktop.PluginPackaging](LanMountainDesktop.PluginPackaging/)` 保留(Host + Launcher CLI 共用),不属于 Launcher 拆分。 + +--- + +## 1. 验收标准(必须全部满足) + +### 1.1 零部署风险 + +- Inno Setup / CI 产物仍只有:`LanMountainDesktop.Launcher.exe` + `app-{version}/` + `.launcher/` +- Host 调用 Launcher 的 CLI 参数、`launch-source`、`apply-update` 路径不变 +- Public IPC routes(`lanmountain.launcher.startup-progress`、`loading-state`)与 Coordinator pipe 不变 +- VeloPack / 更新 apply 状态机(`.current/.partial/.destroy`)行为不变 + +### 1.2 增量可验证 + +- 每个 Phase 结束:编译绿 + 该域新增/既有测试绿 +- 允许「纯移动文件」的 PR 单独提交,行为 diff 为零 + +### 1.3 测试友好 + +- `Startup/`、`Update/`、`Deployment/` 内类型 **无 Avalonia 依赖**,可独立单元测试 +- 每个 `ILaunchPhase`、每个 Update 策略类各有对应测试类 +- 保留并扩展现有 `[LauncherStartupTimeoutPolicyTests](LanMountainDesktop.Tests/LauncherStartupTimeoutPolicyTests.cs)`、`[LauncherMultiInstancePolicyTests](LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs)` + +### 1.4 启动性能 + +- Pipeline 阶段为同步/异步方法调用链,不引入额外进程或网络 +- DI 容器仅在进程入口构建一次;Stage/Phase 实例可复用 Singleton + +### 1.5 代码结构目标 + + +| 对象 | 当前(实测) | 目标 | +| ----------------------------------- | -------------------------------------------- | --------------------------------------------------- | +| `LauncherFlowCoordinator` 全 partial | ~1880 行(859+568+279+…) | **删除**;逻辑迁入 Pipeline + Phases | +| `RunAsync()` 等价逻辑 | 跨 partial ~800+ 行 while/阶段混杂 | **≤80 行** 编排入口,细节在各 Phase | +| `UpdateEngineFacade` | ~1849 行 | 门面 **≤200 行** + Update 内部策略/基础设施类各 **≤300 行** | +| `App.axaml.cs` | ~258 行(已部分瘦身) | **≤120 行**:纯 Avalonia + 一行委托 `LauncherOrchestrator` | +| `LauncherOrchestrator` | 不存在(逻辑在 Coordinator + CompositionRoot 546 行) | **≤250 行**:GUI 入口编排 | +| `LauncherCompositionRoot` | ~546 行 | **≤150 行**:仅 DI 构建 + 入口分发 | + + +--- + +## 2. 目标架构 + +### 2.1 核心类型关系 + +```mermaid +flowchart TB + Program --> EntryRouter + App --> LauncherOrchestrator + EntryRouter --> LauncherOrchestrator + LauncherOrchestrator --> LaunchPipeline + LaunchPipeline --> Phase1[CleanupPhase] + LaunchPipeline --> Phase2[OobeGatePhase] + LaunchPipeline --> Phase3[ApplyUpdatePhase] + LaunchPipeline --> Phase4[LaunchHostPhase] + LaunchPipeline --> Phase5[MonitorStartupPhase] + Phase3 --> IUpdateEngine + Phase4 --> IDeploymentLocator + Phase5 --> IHostStartupMonitor + LauncherCompositionRoot --> ServiceProvider + ServiceProvider --> LaunchPipeline +``` + + + +**命名约定:** + +- `**LauncherOrchestrator`**:GUI 生命周期内的唯一编排入口(取代 `LauncherFlowCoordinator` 对外角色) +- `**LaunchPipeline`**:按序执行 `ILaunchPhase` 列表 +- `**ILaunchPhase**`:原 `ILaunchPipelineStage`;每个 Phase 对应原 `RunAsync` 中一个职责段 + +### 2.2 职责域目录(单项目内) + +``` +LanMountainDesktop.Launcher/ +├── Program.cs # CLI / GUI 路由 +├── App.axaml.cs # 纯 Avalonia(≤120 行) +├── Shell/ +│ ├── LauncherOrchestrator.cs # GUI 编排入口 +│ ├── LauncherCompositionRoot.cs # DI + Entry 分发 +│ ├── LaunchPipeline.cs +│ ├── Phases/ # ILaunchPhase 实现 +│ │ ├── CleanupDeploymentsPhase.cs +│ │ ├── OobeGatePhase.cs +│ │ ├── ApplyPendingUpdatePhase.cs +│ │ ├── LaunchHostPhase.cs +│ │ └── MonitorStartupPhase.cs +│ └── EntryHandlers/ # apply-update / air-app-broker / attach +├── Deployment/ +├── Update/ +│ ├── IUpdateEngine.cs +│ ├── UpdateEngineFacade.cs # IUpdateEngine 薄门面 +│ ├── PendingUpdateDetector.cs +│ ├── UpdateSignatureVerifier.cs +│ ├── LegacyUpdateApplier.cs +│ ├── PlondsUpdateApplier.cs +│ ├── DeploymentActivator.cs +│ ├── UpdateSnapshotStore.cs +│ ├── InstallCheckpointStore.cs +│ ├── RollbackStrategy.cs +│ └── IncomingArtifactsCleaner.cs +├── Startup/ +├── Oobe/ +├── Ipc/ +├── AirApp/ +├── Plugins/ +├── Infrastructure/ +├── Models/ +└── Views/ +``` + +### 2.3 模块依赖规则 + +- `Deployment/`、`Update/`、`Startup/`:**禁止** `using Avalonia` +- `Views/`:**禁止** 引用具体 `UpdateEngineFacade` / `DeploymentLocator`(仅接口或 Orchestrator) +- 跨域:*仅通过 `I` 接口**;Orchestrator/Pipeline 负责装配 + +### 2.4 与 Host 边界(不变) + + +| 能力 | Owner | +| -------------------------- | ------------------------------ | +| OOBE / Splash / 多实例 / 启动检测 | Launcher `Startup/` + `Shell/` | +| 更新 apply / rollback | Launcher `Update/` | +| 插件市场 / pending | Host + PluginPackaging | +| 更新 download | Host → spawn Launcher apply | + + +--- + +## 3. 三大核心拆分(用户指定) + +### 3.1 拆分 `LauncherFlowCoordinator`:`RunAsync` → Pipeline + Phase + +**现状:** 逻辑分散在 4 个 partial,等效一个 1800+ 行 God Class;`RunAsync` 内含清理、OOBE、更新、启动、IPC 监听、超时 while-loop、多实例分支。 + +**目标 API(单项目 `Shell/` 内):** + +```csharp +internal interface ILaunchPhase +{ + string PhaseId { get; } + /// null = 继续下一阶段;非 null = 管道终止并返回结果 + Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken); +} + +internal sealed class LaunchPipeline +{ + public LaunchPipeline(IEnumerable phases) { ... } + public Task RunAsync(LaunchContext context, CancellationToken ct); +} +``` + +**Phase 映射(与原 RunAsync 步骤一一对应):** + + +| Phase | 原 RunAsync 段 | 产出 | +| ------------------------- | --------------------------------------- | ----------------------------- | +| `CleanupDeploymentsPhase` | `CleanupOldDeployments` | 无 UI | +| `ExistingHostProbePhase` | 多实例 / Public IPC 探测 | 可短路成功 | +| `ApplyPendingUpdatePhase` | `_updateEngine.ApplyPendingUpdateAsync` | 失败仍继续 | +| `OobeGatePhase` | migration + OOBE steps | UI via `ILauncherUiPresenter` | +| `LaunchHostPhase` | `LaunchHostWithIpcAsync` | Process + plan | +| `MonitorStartupPhase` | while-loop + IPC + timeout | 调用 `IHostStartupMonitor` | + + +`**LauncherOrchestrator` 职责:** + +- 接收 `SplashWindow`、构建 `LaunchContext`(含 reporter、attempt registry、coordinator server) +- 调用 `LaunchPipeline.RunAsync` +- 管理 Splash/Error 窗口生命周期(委托 `ILauncherUiPresenter`) +- **不含** 更新/部署/IPC 细节 + +**删除清单:** `LauncherFlowCoordinator.cs` 及全部 partial 文件。 + +--- + +### 3.2 拆分 `UpdateEngineFacade` → 门面 + 策略类 + +**现状:** 原 `UpdateEngineFacade` 为 1800+ 行单文件,混合检测、验签、解压、激活、快照、回滚、清理。 + +**目标结构:** + +``` +Update/ +├── IUpdateEngine.cs # 对外契约(未来多进程可原样抽出) +├── UpdateEngineFacade.cs # 门面,编排策略,≤200 行 +├── PendingUpdateDetector.cs # CheckPendingUpdate +├── UpdateSignatureVerifier.cs # manifest + RSA 签名 / hash +├── LegacyUpdateApplier.cs # Legacy zip apply +├── PlondsUpdateApplier.cs # PLONDS manifest apply +├── DeploymentActivator.cs # .current / .partial / .destroy +├── UpdateSnapshotStore.cs # snapshots 读写 +├── InstallCheckpointStore.cs # resume checkpoint +├── RollbackStrategy.cs # rollback CLI/GUI +└── IncomingArtifactsCleaner.cs # CleanupIncomingArtifacts +``` + +**门面方法映射:** + + +| `IUpdateEngine` 公开方法 | 委托策略 | +| ---------------------------- | ------------------------------------------------------ | +| `CheckPendingUpdate()` | `PendingUpdateDetector` | +| `ApplyPendingUpdateAsync()` | Detector → Verifier → Legacy/PLONDS Applier → Activator → Snapshot/Checkpoint | +| `RollbackLatest()` | `RollbackStrategy` | +| `CleanupIncomingArtifacts()` | `IncomingArtifactsCleaner` | +| `DownloadAsync()`(若有) | 保持或拆 `UpdateDownloader` | + + +**测试:** 每个 Strategy 独立 mock `IDeploymentLocator` / 文件系统,不启 Avalonia。 + +--- + +### 3.3 精简 `App.axaml.cs` → 纯 Avalonia + `LauncherOrchestrator` + +**现状:** ~258 行,仍含 apply-update、air-app-broker、preview、coordinator attach 等分支。 + +**目标结构:** + +```csharp +// App.axaml.cs 目标形态(概念) +public override void OnFrameworkInitializationCompleted() +{ + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var context = LauncherRuntimeContext.Current; + var mode = LauncherEntryModeResolver.Resolve(context); + _ = LauncherOrchestrator.RunAsync(desktop, context, mode); + } + base.OnFrameworkInitializationCompleted(); +} +``` + +**从 App 迁出的逻辑 → `Shell/EntryHandlers/`:** + + +| 现 App 分支 | 新 Handler | +| ----------------- | -------------------------------------- | +| `launch` + splash | `GuiLaunchEntryHandler` → Orchestrator | +| `apply-update` | `ApplyUpdateEntryHandler` | +| `air-app-broker` | `AirAppBrokerEntryHandler` | +| debug preview | `PreviewEntryHandler` | + + +**验收:** `App.axaml.cs` ≤120 行;不含 `new UpdateEngineFacade` / `new DeploymentLocator` / while-loop。 + +--- + +## 4. 分阶段执行顺序与 Git 提交点 + +```mermaid +flowchart LR + A[Phase A Startup] --> B1[Phase B1 目录迁移] + B1 --> B2[Phase B2 Pipeline+Orchestrator] + B2 --> B3[Phase B3 App 精简] + B3 --> C[Phase C DI] + B1 --> D[Phase D Update 策略拆分] + C --> E[Phase E 守卫+文档+AOT回归] + D --> E +``` + + + +### Phase A:Startup 子系统 + AOT 生产 bug(优先) + +- 抽出 `Startup/HostStartupMonitor.cs`(从 partial 独立) +- 修复 IPC 连接退避、成功判定统一走 `StartupSuccessTracker` +- Host 侧 `DesktopVisible` 上报对齐(仅日志/时序,不改 IPC 契约) +- 测试 + `**git commit**`: `fix(launcher): extract HostStartupMonitor and harden startup detection` + +### Phase B1:目录迁移(零逻辑变更) + +- 物理移动文件到 `Deployment/`、`Update/`、`Startup/` 等,更新 namespace +- `dotnet build` + test +- `**git commit**`: `refactor(launcher): reorganize into responsibility folders` + +### Phase B2:Pipeline + Phase + LauncherOrchestrator + +- 实现 `ILaunchPhase`、`LaunchPipeline`、`LauncherOrchestrator` +- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试) +- 删除 `LauncherFlowCoordinator*` +- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline` + +### Phase B3:App.axaml.cs 精简 + +- EntryHandlers 提取;App 仅 Avalonia + Orchestrator 委托 +- `**git commit**`: `refactor(launcher): slim App.axaml.cs to Avalonia shell only` + +### Phase C:轻量 DI + +- `LauncherServiceRegistration.cs` + `Microsoft.Extensions.DependencyInjection` +- Program / CliHost / CompositionRoot 统一 `ServiceProvider` +- `**git commit**`: `refactor(launcher): add composition-root DI wiring` + +### Phase D:UpdateEngine 策略拆分(可与 B2 并行,依赖 B1) + +- 已完成:`UpdateEngineFacade` 收敛为 119 行 `IUpdateEngine` 门面 +- 已完成:提取 pending 检测、签名、Legacy/PLONDS apply、激活、快照、checkpoint、回滚、incoming 清理等 Update 内部类 +- 已完成:补 `UpdateStrategyTests` 覆盖关键策略行为 +- `**git commit**`: `refactor(launcher): split UpdateEngine into strategy classes` + +### Phase E:守卫 + 文档 + AOT 回归 + +- 已完成:`LauncherArchitectureTests` 守住无 Avalonia 依赖、`IUpdateEngine` 依赖边界、门面/CompositionRoot 行数阈值 +- 已完成:更新 `docs/LAUNCHER.md`、`docs/ARCHITECTURE.md`、`docs/DEVELOPMENT.md`、`docs/UPDATE_SYSTEM.md` +- 已验证:Debug build、Launcher/Update/Architecture 过滤测试、全量测试;AOT publish 本地 smoke 通过(保留现有 AOT/trim warnings) +- `**git commit**`: `docs(launcher): document module boundaries and add architecture tests` + +--- + +## 5. Phase / Service 测试矩阵 + + +| 组件 | 测试文件 | 覆盖点 | +| ----------------------- | ---------------------------- | --------------------------------- | +| `StartupSuccessTracker` | `StartupSuccessTrackerTests` | Foreground/Tray/Background policy | +| `HostStartupMonitor` | `HostStartupMonitorTests` | 超时、IPC 延迟、ShellStatus 轮询 | +| `LaunchPipeline` | `LaunchPipelineTests` | Phase 短路、失败传播 | +| 各 `ILaunchPhase` | `*PhaseTests` | 单阶段 mock | +| `PendingUpdateDetector` | `PendingUpdateDetectorTests` | 无 pending / corrupt | +| `DeploymentActivator` | `DeploymentActivatorTests` | 标记文件状态机 | +| `RollbackStrategy` | `RollbackStrategyTests` | 快照回退 | +| 命名空间规则 | `LauncherArchitectureTests` | 无 Avalonia 泄漏 | + + +--- + +## 6. 明确不做 + +- 不新建 csproj(Launcher.Deployment 等) +- 不新建 exe / Windows Service +- 不改变 Public IPC / Coordinator IPC 协议 +- 不把插件市场安装迁回 Launcher +- 不为模块间通信引入新 IPC(仅保留现有 Host↔Launcher 契约) + +--- + +## 7. 风险与缓解 + + +| 风险 | 缓解 | +| --------------- | ------------------------------------------------------------------ | +| 大规模移动 merge 冲突 | B1 独立 commit,零逻辑变更 | +| Pipeline 迁移行为回归 | 先写 Phase 级测试再迁代码;保留 `LMD_LAUNCHER_LEGACY_COORDINATOR=1` 开关一个版本(可选) | +| AOT + DI | 显式注册,禁止反射扫描;`PublishAot` CI 步骤验证 | +| Update 拆分遗漏路径 | CLI `update *` 与 GUI apply-update 同一 `IUpdateEngine` 门面 | + + +--- + +## 8. Git 工作流(Agent 自主提交) + +**原则:** 每个 Phase 验证通过后立即提交;不累积巨型 uncommitted diff。 + +**Commit 前检查(每个 commit 必做):** + +```bash +dotnet build LanMountainDesktop.slnx -c Debug +dotnet test LanMountainDesktop.slnx -c Debug --filter "FullyQualifiedName~Launcher" +``` + +**Commit message 风格(与仓库一致):** + +``` +refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline + +Pipeline + Phase pattern; LauncherOrchestrator becomes GUI entry. +No deployment or IPC contract changes. +``` + +**禁止:** `git push --force`、修改 git config、跳过 hooks(除非 hook 失败需修复后新 commit)。 + +**建议分支:** `refactor/launcher-internal-modularization`(单 long-lived 分支,按 Phase 连续 commit;或每 Phase 一个 PR 由用户决定 merge 时机)。 + +--- + +## 9. 整体完成定义(Definition of Done) + +- 无 `LauncherFlowCoordinator` 源文件 +- `App.axaml.cs` ≤120 行,仅 Avalonia + Orchestrator 委托 +- `UpdateEngineFacade` 巨型文件已替换为薄门面 + Update 内部策略/基础设施类 +- 职责域目录就位,架构测试通过 +- 全量 Launcher 相关测试 + AOT publish smoke 通过 +- 安装包结构与 IPC 拓扑与重构前一致 +- 每个 Phase 有对应 Git commit,工作区 clean diff --git a/docs/UPDATE_SYSTEM.md b/docs/UPDATE_SYSTEM.md index 4b6e358..6556f25 100644 --- a/docs/UPDATE_SYSTEM.md +++ b/docs/UPDATE_SYSTEM.md @@ -36,7 +36,7 @@ UpdateCheckService.CheckForUpdateAsync() ↓ 有新版本? ──No→ 继续启动 ↓ Yes -UpdateEngineService.DownloadAsync() +IUpdateEngine.DownloadAsync() / UpdateEngineFacade ├─ 下载 files-{version}.json ├─ 下载 files-{version}.json.sig └─ 下载 delta-{old}-to-{new}.zip (或完整包) @@ -45,17 +45,13 @@ UpdateEngineService.DownloadAsync() ↓ 下次启动时 ↓ -UpdateEngineService.ApplyPendingUpdate() - ├─ 验证签名 - ├─ 创建 app-{new}/ 目录 - ├─ 标记 .partial - ├─ 解压增量包 - ├─ 从旧版本复用未变更文件 - ├─ 验证所有文件 SHA256 - ├─ 删除 .partial - ├─ 添加 .current 到新版本 - ├─ 标记旧版本 .destroy - └─ 保存更新快照 +IUpdateEngine.ApplyPendingUpdateAsync() / UpdateEngineFacade + ├─ PendingUpdateDetector 识别 Legacy/PLONDS pending 更新 + ├─ UpdateSignatureVerifier 验证签名和哈希 + ├─ LegacyUpdateApplier 或 PlondsUpdateApplier 应用文件 + ├─ DeploymentActivator 切换 .current/.partial/.destroy + ├─ UpdateSnapshotStore / InstallCheckpointStore 记录快照和断点 + └─ IncomingArtifactsCleaner 清理 incoming 缓存 ↓ 启动新版本 ↓ @@ -448,4 +444,3 @@ private static void EnsurePathWithinRoot(string targetPath, string rootPath) - Release pipeline now produces VeloPack native assets ( eleases.win.json, *.nupkg, RELEASES). - Launcher remains the installer and rollback authority; only package generation moved to VeloPack. - Legacy iles.json + update.zip generation remains available only as a disabled fallback path in CI. - diff --git a/docs/auto_commit_md/20260528_1ee6e68.md b/docs/auto_commit_md/20260528_1ee6e68.md new file mode 100644 index 0000000..7e3d72b --- /dev/null +++ b/docs/auto_commit_md/20260528_1ee6e68.md @@ -0,0 +1,88 @@ +# Git 提交分析报告 + +## 基本信息 +- **哈希**: 1ee6e68f33f0a1bbeccf7cef8f3767e65d6916c9 +- **短哈希**: 1ee6e68 +- **作者**: lincube <lincube3@hotmail.com> +- **时间**: 2026-05-28 10:28:31 +0800 +- **合入作者**: Cursor <cursoragent@cursor.com> + +## 提交信息摘要 +refactor(launcher): converge plugin pending to Host via PluginPackaging + +## 变更统计 +| 指标 | 数值 | +|------|------| +| 变更文件数 | 32 | +| 新增行数 | 881 | +| 删除行数 | 1917 | +| 净变化 | -1036 | + +## 详细变更分析 + +### 架构变更概述 +此提交进行了重要的架构调整:将插件待处理(pending)功能从 Launcher 移到了 Host(主程序),并引入了新的 PluginPackaging 项目。 + +### 新增项目 +1. **LanMountainDesktop.PluginPackaging** - 新的插件打包项目 + - `PendingPluginUpgradeStore.cs` - 待处理插件升级存储 + - `PluginPackageInstallOptions.cs` - 插件包安装选项 + - `PluginPackageInstallResult.cs` - 插件包安装结果 + - `PluginPackageInstaller.cs` - 插件包安装器 + - `PluginPackageManifest.cs` - 插件包清单 + - `PluginPackageManifestReader.cs` - 插件包清单读取器 + - `PluginPackagingConstants.cs` - 插件打包常量 + +### 删除项目 +1. **LanMountainDesktop.PluginUpgradeHelper** - 已删除的插件升级辅助项目 + - `Program.cs` - 主程序(372行) + +### 主要删除的文件 +1. `FlexibleHostLocator.cs` - 灵活主机定位器(634行) +2. `UpdateCheckService.cs` - 更新检查服务(161行) +3. `LauncherClient.cs` - Launcher 客户端(210行) +4. `CliLauncherUpdateBridge.cs` - CLI Launcher 更新桥接(48行) +5. `IpcLauncherUpdateBridge.cs` - IPC Launcher 更新桥接(171行) + +### 主要变更的文件 +1. `PendingPluginUpgradeService.cs` - 大幅简化 +2. `PluginMarketInstallService.cs` - 大幅简化,移除了通过 Launcher 安装的逻辑 +3. `PluginRuntimeService.cs` - 新增 `ApplyPendingPluginOperations()` 方法 +4. 多个文档文件更新 - 反映架构变更 + +### 架构变化要点 + +#### 1. 职责转移 +- **之前**:Launcher 负责插件待处理安装/升级 +- **现在**:Host(主程序)负责在启动时应用待处理插件操作 + +#### 2. 新的流程 +1. 插件市场下载包到用户的待处理队列 +2. 下次 Host 启动时,在插件发现前应用待处理操作 +3. Launcher 保留 CLI 命令作为维护兼容性入口 + +#### 3. 新增功能 +- `PluginRuntimeService.ApplyPendingPluginOperations()` - 应用待处理插件操作 +- `PendingPluginUpgradeService.AddPendingInstallOrUpgrade()` - 添加待处理安装或升级 +- 新增 `RestartRequired` 状态到 `AirAppMarketInstallState` + +## 代码审查要点 + +### 优势 +1. **架构更清晰**:Launcher 专注于版本管理和更新,Host 专注于插件运行时 +2. **减少权限需求**:插件安装不需要通过 Launcher,减少了权限提升的场景 +3. **简化代码**:删除了大量桥接代码和复杂的协调逻辑 +4. **更好的用户体验**:插件安装在 Host 启动时完成,用户体验更流畅 + +### 潜在风险 +1. **功能回归风险**:删除了大量代码,需要确保所有功能都已正确迁移 +2. **兼容性**:CLI 命令作为兼容性入口,需要确保仍然正常工作 +3. **升级路径**:现有用户的待处理插件队列需要正确迁移 +4. **错误处理**:Host 启动时的插件安装失败需要妥善处理 + +### 建议 +1. 完整测试插件市场安装/升级流程 +2. 测试 CLI 插件命令的兼容性 +3. 测试待处理插件操作在 Host 启动时的应用 +4. 验证升级路径,确保现有用户平滑过渡 +5. 检查错误处理和日志记录是否完善 diff --git a/docs/auto_commit_md/20260528_1ef47c7.md b/docs/auto_commit_md/20260528_1ef47c7.md new file mode 100644 index 0000000..ad26c1a --- /dev/null +++ b/docs/auto_commit_md/20260528_1ef47c7.md @@ -0,0 +1,83 @@ +# Git 提交分析报告 + +## 基本信息 +- **哈希**: 1ef47c780bea380088d2615e8f4ec7d478ca5aa5 +- **短哈希**: 1ef47c7 +- **作者**: lincube <lincube3@hotmail.com> +- **时间**: 2026-05-28 11:13:14 +0800 +- **合入作者**: Cursor <cursoragent@cursor.com> + +## 提交信息摘要 +refactor(launcher): add DI, IUpdateEngine facade, and architecture tests + +## 变更统计 +| 指标 | 数值 | +|------|------| +| 变更文件数 | 31 | +| 新增行数 | 168 | +| 删除行数 | 1512 | +| 净变化 | -1344 | + +## 详细变更分析 + +### 新增文件 +1. `LanMountainDesktop.Launcher/Update/IUpdateEngine.cs` - 新增更新引擎接口 +2. `LanMountainDesktop.Tests/LauncherArchitectureTests.cs` - 新增架构测试文件 + +### 删除文件 +1. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs` +2. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.HostStartupMonitor.cs` +3. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.LaunchOrchestrator.cs` +4. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.UiPresenter.cs` + +### 重命名文件 +1. `LanMountainDesktop.Launcher/Update/UpdateEngineService.cs` → `LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs` + +### 主要变更点 + +#### 1. 依赖注入重构 +- 新增 `LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs` - DI 服务注册 +- 修改 `LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs` - 组合根 +- 更新 `LanMountainDesktop.Launcher/Program.cs` - 入口点调整 + +#### 2. 更新引擎重构 +- 新增 `IUpdateEngine` 接口,定义更新引擎契约 +- `UpdateEngineService` 重命名为 `UpdateEngineFacade` 并实现接口 +- 将更新相关逻辑从巨大的协调器中解耦 + +#### 3. 协调器移除 +- 删除了 `LauncherFlowCoordinator` 及其分部类(共约 1436 行代码) +- 功能已由 `LauncherOrchestrator` + `LaunchPipeline` 替代 + +#### 4. 架构测试 +- 新增 `LauncherArchitectureTests` 测试: + - 验证 Deployment/Update/Startup/Infrastructure 命名空间不依赖 Avalonia + - 确认 `LauncherFlowCoordinator` 已不存在 + +#### 5. 命名空间调整 +- 多个视图文件的 `using` 语句从 `LanMountainDesktop.Launcher.Services` 改为 `LanMountainDesktop.Launcher.Infrastructure` + +#### 6. 文档更新 +- 更新 `docs/LAUNCHER.md`,反映新的架构设计 +- 移除对 `LauncherFlowCoordinator` 的描述 +- 添加对 `LauncherOrchestrator` / `LaunchPipeline` 的说明 +- 文档化 `IUpdateEngine` / `UpdateEngineFacade` + +## 代码审查要点 + +### 优势 +1. **架构清晰**:通过 DI 和接口解耦,代码结构更清晰 +2. **责任分离**:将巨型协调器拆分为多个职责单一的组件 +3. **可测试性**:新增架构测试,确保关键架构约束得到执行 +4. **文档更新**:同步更新了架构文档 +5. **代码简化**:净减少 1344 行代码,去除了冗余 + +### 潜在风险 +1. **大规模删除**:删除了大量代码,需要确保没有遗漏功能 +2. **依赖注入引入**:新引入 DI 框架,需要验证服务注册是否完整 +3. **接口变更**:`UpdateEngineService` 重命名并改为接口实现,需确保所有引用都已更新 + +### 建议 +1. 运行完整的测试套件,特别是启动流程和更新相关测试 +2. 进行端到端测试,验证启动流程是否正常工作 +3. 检查插件相关功能是否仍然正常(提到了插件 pending 处理) diff --git a/docs/auto_commit_md/20260528_545dee8.md b/docs/auto_commit_md/20260528_545dee8.md new file mode 100644 index 0000000..5aa1dab --- /dev/null +++ b/docs/auto_commit_md/20260528_545dee8.md @@ -0,0 +1,45 @@ +# Git 提交分析报告 + +## 基本信息 +- **哈希**: 545dee85a79942a18b18a81a86a53bb700161f9d +- **短哈希**: 545dee8 +- **作者**: lincube <lincube3@hotmail.com> +- **时间**: 2026-05-28 10:28:16 +0800 +- **合入作者**: Cursor <cursoragent@cursor.com> + +## 提交信息摘要 +fix(launcher): wire HostStartupMonitor into launch flow + +## 变更统计 +| 指标 | 数值 | +|------|------| +| 变更文件数 | 1 | +| 新增行数 | 1 | +| 删除行数 | 1 | +| 净变化 | 0 | + +## 详细变更分析 + +### 变更的文件 +`LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs` + +### 具体变更 +修改了 `HostStartupMonitor` 中的 `Request` 记录的 `ComposeLaunchDetails` 函数签名: +- **之前**:`Func>` +- **之后**:`Func>` + +移除了第三个布尔参数。 + +## 代码审查要点 + +### 优势 +1. **简化接口**:减少了不必要的参数 +2. **保持兼容性**:这是一个小的调整,不会造成大的影响 + +### 潜在风险 +1. **调用点需要同步更新**:需要确保所有调用 `HostStartupMonitor` 的地方都已同步更新 +2. **参数用途不明确**:不清楚移除的参数原本的用途 + +### 建议 +1. 检查所有调用点,确保已同步更新 +2. 运行相关测试,确保功能正常 diff --git a/docs/auto_commit_md/20260528_a26b6fa.md b/docs/auto_commit_md/20260528_a26b6fa.md new file mode 100644 index 0000000..0561b16 --- /dev/null +++ b/docs/auto_commit_md/20260528_a26b6fa.md @@ -0,0 +1,98 @@ +# Git 提交分析报告 + +## 基本信息 +- **哈希**: a26b6faace509f2ff8806e95fe5891ce4b325fc4 +- **短哈希**: a26b6fa +- **作者**: lincube <lincube3@hotmail.com> +- **时间**: 2026-05-28 11:03:49 +0800 +- **合入作者**: Cursor <cursoragent@cursor.com> + +## 提交信息摘要 +refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline and slim App shell + +## 变更统计 +| 指标 | 数值 | +|------|------| +| 变更文件数 | 19 | +| 新增行数 | 2517 | +| 删除行数 | 788 | +| 净变化 | +1729 | + +## 详细变更分析 + +### 新增文件 +1. `LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs` - 启动入口处理器 +2. `LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs` - 预览入口处理器 +3. `LanMountainDesktop.Launcher/Shell/LauncherCompositionRoot.cs` - 组合根 +4. `LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs` - 启动协调器 +5. `LanMountainDesktop.Launcher/Startup/ExistingHostProbe.cs` - 现有主机探测 +6. `LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs` - 主机启动模型 +7. `LanMountainDesktop.Launcher/Startup/HostLaunchService.cs` - 主机启动服务 +8. `LanMountainDesktop.Launcher/Startup/LaunchAttemptDetails.cs` - 启动尝试详情 +9. `LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs` - 启动管道 +10. `LanMountainDesktop.Launcher/Startup/LaunchUiPresenter.cs` - UI 展示器 +11. `LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs` - 应用待更新阶段 +12. `LanMountainDesktop.Launcher/Startup/Phases/CleanupDeploymentsPhase.cs` - 清理部署阶段 +13. `LanMountainDesktop.Launcher/Startup/Phases/ExistingHostProbePhase.cs` - 现有主机探测阶段 +14. `LanMountainDesktop.Launcher/Startup/Phases/LaunchHostPhase.cs` - 启动主机阶段 +15. `LanMountainDesktop.Launcher/Startup/Phases/MonitorStartupPhase.cs` - 监控启动阶段 +16. `LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs` - OOBE 关卡阶段 + +### 主要变更文件 +1. `LanMountainDesktop.Launcher/App.axaml.cs` - 大幅精简,移除大量逻辑 + +### 主要变更点 + +#### 1. 架构重构 +- **移除巨型协调器**:将 `LauncherFlowCoordinator` 的功能拆分到多个职责单一的类中 +- **引入管道模式**:新增 `LaunchPipeline` 来管理启动流程 +- **阶段化设计**:将启动流程拆分为多个独立的阶段(Phase) + +#### 2. 启动阶段设计 +新增以下启动阶段: +- `CleanupDeploymentsPhase` - 清理部署 +- `ExistingHostProbePhase` - 现有主机探测 +- `ApplyPendingUpdatePhase` - 应用待更新 +- `OobeGatePhase` - OOBE 关卡 +- `LaunchHostPhase` - 启动主机 +- `MonitorStartupPhase` - 监控启动 + +#### 3. 核心组件 +- **LauncherOrchestrator** - 负责整体协调 +- **LauncherCompositionRoot** - 组合根,负责对象组装 +- **HostLaunchService** - 主机启动服务 +- **ExistingHostProbe** - 现有主机探测逻辑 +- **LaunchUiPresenter** - UI 展示逻辑 + +#### 4. 入口处理 +- 新增 `LaunchEntryHandlers` 和 `PreviewEntryHandler` +- 将入口处理逻辑从 `App` 类中移出 + +#### 5. App 类精简 +- `App.axaml.cs` 从 815 行大幅减少 +- 逻辑被分散到各个专门的类中 + +#### 6. 测试更新 +- `LauncherAirAppLifecycleServiceTests` 更新以使用新的入口处理器 +- `LauncherGlobalUsings.cs` 添加新的命名空间引用 + +## 代码审查要点 + +### 优势 +1. **职责分离**:每个类职责更加单一清晰 +2. **可扩展性**:通过阶段模式,方便添加新的启动阶段 +3. **可测试性**:各个组件可以独立测试 +4. **代码组织**:文件结构更清晰,便于维护 +5. **增量变化**:虽然新增了很多代码,但这是为了更好的架构 + +### 潜在风险 +1. **复杂度增加**:组件数量增多,理解整个流程需要查看更多文件 +2. **阶段顺序依赖**:阶段之间有依赖关系,需要确保顺序正确 +3. **状态管理**:`LaunchContext` 需要在多个阶段之间传递状态,需确保状态一致性 +4. **回归风险**:大规模重构可能引入隐藏的 bug + +### 建议 +1. 为每个阶段编写单元测试 +2. 编写集成测试验证完整启动流程 +3. 考虑添加流程图文档,帮助理解各阶段关系 +4. 进行充分的端到端测试,确保各种启动场景正常工作 diff --git a/docs/auto_commit_md/20260528_b219f10.md b/docs/auto_commit_md/20260528_b219f10.md new file mode 100644 index 0000000..3b14964 --- /dev/null +++ b/docs/auto_commit_md/20260528_b219f10.md @@ -0,0 +1,109 @@ +# Git 提交分析报告 + +## 基本信息 +- **哈希**: b219f109ec1c69c21d57be7ac3e03dcd6f981877 +- **短哈希**: b219f10 +- **作者**: lincube <lincube3@hotmail.com> +- **时间**: 2026-05-28 10:43:30 +0800 +- **合入作者**: Cursor <cursoragent@cursor.com> + +## 提交信息摘要 +refactor(launcher): reorganize into responsibility folders + +## 变更统计 +| 指标 | 数值 | +|------|------| +| 变更文件数 | 57 | +| 新增行数 | 92 | +| 删除行数 | 197 | +| 净变化 | -105 | + +## 详细变更分析 + +### 主要目录重组 +此提交主要是将文件从单一的 `Services` 文件夹重新组织到按职责划分的文件夹中: + +#### 1. AirApp 相关 +- 从 `Services/AirApp/` 移动到 `AirApp/` +- 涉及文件: + - `AirAppHostLocator.cs` + - `AirAppInstanceKey.cs` + - `IAirAppProcessStarter.cs` + - `LauncherAirAppLifecycleIpcHost.cs` + - `LauncherAirAppLifecycleService.cs` + +#### 2. Deployment 相关 +- 从 `Services/` 移动到 `Deployment/` +- 涉及文件: + - `DeploymentLocator.cs` + - `HostDiscoveryOptions.cs` + - `HostLaunchPlan.cs` + - `HostResolutionResult.cs` + - `LegacyVersionDetector.cs` + +#### 3. Infrastructure 相关 +- 从 `Services/` 移动到 `Infrastructure/` +- 涉及文件: + - `Commands.cs` + - `DataLocationResolver.cs` + - `DeferredSplashStageReporter.cs` + - `DotNetRuntimeProbe.cs` + - `ISplashStageReporter.cs` + - `LanguagePreferenceService.cs` + - `LauncherBackgroundService.cs` + - `LauncherDebugSettingsStore.cs` + - `LauncherExecutionContext.cs` + - `Logger.cs` + - `ThemeService.cs` + +#### 4. Oobe 相关 +- 从 `Services/` 移动到 `Oobe/` +- 涉及文件: + - `DataLocationOobeStep.cs` + - `HostAppSettingsOobeMerger.cs` + - `IOobeStep.cs` + - `LauncherWindowsStartupService.cs` + - `OobeStateService.cs` + - `PrivacyAgreementService.cs` + - `WelcomeOobeStep.cs` + +#### 5. Startup 相关 +- 从 `Services/` 移动到 `Startup/` +- 涉及文件: + - `StartupAttemptRegistry.cs` + - `StartupDiagnostics.cs` + - `StartupSuccessTracker.cs` + +#### 6. Update 相关 +- 从 `Services/` 移动到 `Update/` +- 涉及文件: + - `IUpdateProgressReporter.cs` + - `NullUpdateProgressReporter.cs` + - `UpdateCheckService.cs` + - `UpdateEngineService.cs` + +### 其他变更 +1. 新增 `GlobalUsings.cs` - 添加全局 using 语句 +2. 新增 `LauncherGlobalUsings.cs`(测试项目)- 测试项目的全局 using +3. `OobeWindow.axaml.cs` - 更新命名空间引用 +4. 多个测试文件更新 - 更新命名空间引用 + +## 代码审查要点 + +### 优势 +1. **更好的组织结构**:按职责划分文件夹,代码组织更清晰 +2. **易于导航**:开发者可以更快找到相关功能的文件 +3. **模块化**:每个文件夹代表一个功能模块 +4. **可维护性提升**:相关文件放在一起,便于维护 + +### 潜在风险 +1. **合并冲突风险**:大量文件移动可能导致合并冲突 +2. **引用更新不完整**:需要确保所有 using 语句和引用都已更新 +3. **文档需要同步**:相关文档可能需要更新以反映新的文件结构 +4. **Git 历史**:文件移动可能会影响 Git 历史追踪 + +### 建议 +1. 运行完整的编译检查,确保没有遗漏的引用 +2. 运行测试套件,确保所有测试通过 +3. 检查相关文档是否需要更新 +4. 考虑为新的文件夹结构添加 README 说明 diff --git a/docs/auto_commit_md/20260528_ebe35d6.md b/docs/auto_commit_md/20260528_ebe35d6.md new file mode 100644 index 0000000..8c03498 --- /dev/null +++ b/docs/auto_commit_md/20260528_ebe35d6.md @@ -0,0 +1,103 @@ +# Git 提交分析报告 + +## 基本信息 +- **哈希**: ebe35d6f91b844dcdf3729d0d894875804749e9a +- **短哈希**: ebe35d6 +- **作者**: lincube <lincube3@hotmail.com> +- **时间**: 2026-05-28 10:27:33 +0800 +- **合入作者**: Cursor <cursoragent@cursor.com> + +## 提交信息摘要 +fix(launcher): extract startup subsystem and harden IPC detection + +## 变更统计 +| 指标 | 数值 | +|------|------| +| 变更文件数 | 14 | +| 新增行数 | 1990 | +| 删除行数 | 1535 | +| 净变化 | +455 | + +## 详细变更分析 + +### 概述 +此提交将启动子系统从庞大的 `LauncherFlowCoordinator` 中提取出来,形成独立的、职责更清晰的模块,并加强了 IPC 检测机制。 + +### 新增文件 + +#### 1. 启动子系统核心组件 +- `HostActivationPolicy.cs` - 主机激活策略 +- `HostStartupMonitor.cs` - 主机启动监控器 +- `PublicIpcConnection.cs` - 公共 IPC 连接 +- `StartupDiagnostics.cs` - 启动诊断 +- `StartupSuccessTracker.cs` - 启动成功跟踪器 +- `StartupTimeoutPolicy.cs` - 启动超时策略 + +#### 2. 分部类文件 +- `LauncherFlowCoordinator.HostStartupMonitor.cs` - 主机启动监控器分部类 +- `LauncherFlowCoordinator.LaunchOrchestrator.cs` - 启动协调器分部类 +- `LauncherFlowCoordinator.UiPresenter.cs` - UI 展示器分部类 + +#### 3. 测试文件 +- `HostActivationPolicyTests.cs` - 主机激活策略测试 +- `StartupSuccessTrackerTests.cs` - 启动成功跟踪器测试 + +### 主要变更的文件 +- `LauncherFlowCoordinator.cs` - 大幅简化(1612行 → 更少) +- `LauncherMultiInstancePolicyTests.cs` - 更新命名空间引用 +- `LauncherStartupTimeoutPolicyTests.cs` - 更新测试以引用新文件 + +### 核心功能改进 + +#### 1. 提取启动子系统 +- 将启动相关逻辑从 `LauncherFlowCoordinator` 中分离 +- 形成独立的、可测试的组件 +- 每个组件职责单一清晰 + +#### 2. 主机激活策略 (`HostActivationPolicy`) +- `ShouldProbeExistingHostBeforeLaunch()` - 决定是否在启动前探测现有主机 +- `IsExistingHostReadyForLauncherDecision()` - 检查现有主机是否准备好 +- `IsRecoverableActivationFailure()` - 判断激活失败是否可恢复 +- 退出码分类方法 + +#### 3. 启动成功跟踪器 (`StartupSuccessTracker`) +- 支持多种启动策略(前台、重启后台、重启托盘) +- 根据不同阶段判断启动成功 +- 提供恢复成功状态构建 + +#### 4. 启动超时策略 (`StartupTimeoutPolicy`) +- 软超时:30秒 +- 硬超时:120秒 +- IPC 连接超时配置 +- 重试间隔配置 + +#### 5. 启动诊断 (`StartupDiagnostics`) +- 可通过环境变量 `LMD_LAUNCHER_STARTUP_DIAG=1` 启用 +- 记录启动事件到 JSONL 文件 +- 追踪 Shell 状态变化 + +#### 6. 加强 IPC 检测 (`PublicIpcConnection`) +- 带退避的连接尝试 +- 更好的错误处理 +- 超时控制 + +## 代码审查要点 + +### 优势 +1. **更好的模块化**:代码组织更清晰,每个组件职责单一 +2. **可测试性提升**:提取出的组件都有对应的测试 +3. **诊断能力增强**:新增启动诊断功能,便于问题排查 +4. **IPC 检测更健壮**:带退避的重试机制,超时配置更精细 +5. **代码可读性**:庞大的协调器被拆解,更易理解和维护 + +### 潜在风险 +1. **回归风险**:大规模重构可能引入隐藏的 bug +2. **组件协调**:需要确保新组件之间的交互正确 +3. **测试覆盖**:需要确保所有路径都有充分的测试 + +### 建议 +1. 完整测试启动流程,包括各种边缘情况 +2. 测试多实例场景 +3. 测试重启场景(托盘、后台等) +4. 启用启动诊断,验证诊断功能正常 +5. 测试超时场景 diff --git a/docs/launcher_architecture_analysis.md b/docs/launcher_architecture_analysis.md new file mode 100644 index 0000000..312f35c --- /dev/null +++ b/docs/launcher_architecture_analysis.md @@ -0,0 +1,286 @@ +# Launcher 架构拆分评估报告 + +## 1. 现状分析 + +### 1.1 Launcher 当前职责清单 + +根据代码审查,[LanMountainDesktop.Launcher](file:///d:/github/LanMountainDesktop/LanMountainDesktop.Launcher) 当前承担 **6 个主要职责域**: + +| 职责域 | 核心文件 | 代码量 | 复杂度 | +|--------|---------|--------|--------| +| **OOBE 首次体验** | `OobeStateService`, `OobeWindow`, `WelcomeOobeStep`, `DataLocationOobeStep`, `PrivacyAgreementService` | ~28 KB | 中 | +| **Splash / 启动协调** | `LauncherFlowCoordinator`, `SplashWindow`, `LoadingDetailsWindow`, `StartupAttemptRegistry` | ~120 KB | **极高** | +| **更新引擎** | `UpdateEngineService`, `UpdateCheckService` | ~77 KB | **极高** | +| **插件管理** | `PluginInstallerService`, `PluginUpgradeQueueService` | ~13 KB | 低 | +| **部署/版本管理** | `DeploymentLocator`, `FlexibleHostLocator`, `DotNetRuntimeProbe`, `LegacyVersionDetector` | ~70 KB | 高 | +| **Air APP 生命周期** | `AirApp/*`, `LauncherBackgroundService` | ~21 KB | 中 | + +**总计:~673 KB 源代码,95 个 .cs/.axaml 文件** + +### 1.2 关键耦合热点 + +#### 热点 1:[LauncherFlowCoordinator.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs) — **90 KB / 2034 行** + +这是整个 Launcher 最大的单文件,负责: +- OOBE → Splash → Update → Plugin → Host Launch 的完整编排 +- 多实例检测与协调 (IPC coordinator) +- 主程序启动、进程监控、超时处理 +- 激活恢复 (activation recovery) +- 所有 UI 窗口的生命周期管理 + +> [!WARNING] +> 这个文件是当前最大的架构债务。它同时了解所有职责域,是修改任何启动行为都必须触碰的瓶颈文件。 + +#### 热点 2:[UpdateEngineService.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs) — **72 KB / 1850 行** + +包含两套完整的更新应用流程(Legacy 和 PLONDS),内嵌: +- 签名验证 +- 增量文件应用 +- SHA-256/SHA-512 校验 +- 回滚机制 +- 快照管理 +- 安装检查点与断点续传 + +#### 热点 3:[App.axaml.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop.Launcher/App.axaml.cs) — **34 KB / 850 行** + +App 入口承担了过多的运行时编排逻辑,包括: +- Coordinator IPC 服务器的创建和管理 +- Air APP IPC broker 模式 +- 主程序进程存活监控 +- 失败恢复 UI 流程 + +### 1.3 职责域间的依赖关系 + +```mermaid +graph TD + A["App.axaml.cs
(入口编排)"] --> B["LauncherFlowCoordinator
(流程协调)"] + A --> C["Air APP Broker"] + A --> D["Coordinator IPC"] + B --> E["OobeStateService"] + B --> F["UpdateEngineService"] + B --> G["PluginInstallerService"] + B --> H["DeploymentLocator"] + B --> I["StartupAttemptRegistry"] + B --> D + F --> H + G --> J["PluginUpgradeQueueService"] + C --> K["LauncherAirAppLifecycleService"] + + style A fill:#ff6b6b,color:#fff + style B fill:#ff6b6b,color:#fff + style F fill:#ff9f43,color:#fff +``` + +> [!IMPORTANT] +> 红色节点是耦合最严重的热点。`LauncherFlowCoordinator` 直接依赖几乎所有其他服务。 + +--- + +## 2. 方案评估 + +### Option A:多项目拆分 + +将 Launcher 拆分成独立的 .NET 项目/可执行文件: + +| 拆分后的项目 | 职责 | +|-------------|------| +| `LanMountainDesktop.Launcher` | 精简入口,仅做 OOBE + Splash + 编排调度 | +| `LanMountainDesktop.UpdateService` | 更新检查、下载、应用、回滚 | +| `LanMountainDesktop.PluginService` | 插件安装、升级队列 | +| `LanMountainDesktop.DeploymentManager` | 版本目录管理、主机发现 | + +#### 优点 +- 最大化隔离:每个服务可独立部署、独立更新 +- 更新引擎可以在 Launcher 自身不运行时被调用(例如计划任务) +- 故障隔离:插件安装崩溃不影响更新流程 + +#### 缺点 + +> [!CAUTION] +> **这些缺点在当前阶段是致命的。** + +- **进程间通信成本巨大**:当前 `LauncherFlowCoordinator` 的 2034 行编排逻辑严重依赖同进程内的同步/异步调用和共享状态(`TaskCompletionSource`、进程对象引用、UI Dispatcher 调度)。拆成多进程意味着每个交互点都需要 IPC 管道 + 序列化 + 超时处理 + 错误恢复。 +- **启动延迟增加**:当前 Launcher 启动到 Host 启动的路径已经很长(OOBE → 更新 → 插件 → 主机发现 → Host 进程启动 → IPC 握手)。多进程会在每个阶段增加进程启动开销。 +- **安装包膨胀**:每个独立可执行文件都需要自己的运行时依赖,即使共享 Avalonia SDK。 +- **复杂的部署协调**:Launcher 自身不可被拆分更新——它就是更新的入口。如果 `UpdateService` 是独立进程,谁来启动它?又需要一个 meta-launcher。 +- **当前代码并未准备好**:`LauncherFlowCoordinator.RunAsync()` 是一个巨大的异步方法,内部有十几个局部变量和闭包在多个 await 之间共享状态。这些状态不可能简单地序列化为 IPC 消息。 + +#### 改造工程量估算 +- **IPC 层**:需新增 ~8-10 个 IPC 契约、每个有请求/响应/通知消息 +- **进程管理**:需要为每个子服务编写进程启动、健康检查、重启逻辑 +- **状态同步**:`StartupAttemptRegistry` 的锁文件机制需要扩展为跨进程锁 +- **估计 3-5 人周**,且引入大量新的故障模式 + +--- + +### Option B:单项目内部解耦(推荐) + +保持单一 Launcher 可执行文件,通过以下手段实现内部解耦: + +#### 阶段 1:职责分层(重构目录结构) + +``` +LanMountainDesktop.Launcher/ +├── Program.cs # 入口(保持精简) +├── App.axaml.cs # Avalonia 应用(精简到 <200 行) +├── Core/ # 核心编排层 +│ ├── LauncherOrchestrator.cs # 从 App.axaml.cs 提取的运行时编排 +│ ├── StartupPipeline.cs # 从 FlowCoordinator 提取的阶段管道 +│ └── StartupPhase.cs # 每个阶段的抽象接口 +├── Deployment/ # 版本管理域 +│ ├── DeploymentLocator.cs +│ ├── FlexibleHostLocator.cs +│ ├── HostLaunchPlan.cs +│ ├── DotNetRuntimeProbe.cs +│ └── LegacyVersionDetector.cs +├── Update/ # 更新引擎域 +│ ├── UpdateEngineService.cs # 重构后 <400 行 +│ ├── LegacyUpdateApplier.cs # 从 UpdateEngine 提取 +│ ├── PlondsUpdateApplier.cs # 从 UpdateEngine 提取 +│ ├── SignatureVerifier.cs # 从 UpdateEngine 提取 +│ ├── UpdateCheckService.cs +│ └── UpdateSnapshotManager.cs # 从 UpdateEngine 提取 +├── Plugin/ # 插件管理域 +│ ├── PluginInstallerService.cs +│ └── PluginUpgradeQueueService.cs +├── Oobe/ # OOBE 域 +│ ├── OobeStateService.cs +│ ├── IOobeStep.cs +│ ├── WelcomeOobeStep.cs +│ ├── DataLocationOobeStep.cs +│ └── PrivacyAgreementService.cs +├── AirApp/ # Air APP 域(已部分独立) +│ └── ... +├── Coordination/ # 多实例协调域 +│ ├── StartupAttemptRegistry.cs +│ ├── LauncherCoordinatorIpcServer.cs +│ └── LauncherCoordinatorIpcClient.cs +├── Views/ # UI 层 +│ └── ... +├── ViewModels/ # ViewModel 层 +│ └── ... +└── Models/ # 数据模型 + └── ... +``` + +#### 阶段 2:拆分 LauncherFlowCoordinator + +将 2034 行的 `RunAsync()` 重构为 Pipeline + Phase 模式: + +```csharp +// 启动管道定义(伪代码) +public class StartupPipeline +{ + private readonly IReadOnlyList _phases; + + public async Task ExecuteAsync(StartupContext context) + { + foreach (var phase in _phases) + { + var result = await phase.ExecuteAsync(context); + if (!result.Continue) return result.LauncherResult; + } + return LauncherResult.Success(); + } +} + +// 各阶段独立实现 +public class CleanupPhase : IStartupPhase { ... } +public class OobePhase : IStartupPhase { ... } +public class UpdatePhase : IStartupPhase { ... } +public class PluginUpgradePhase : IStartupPhase { ... } +public class HostLaunchPhase : IStartupPhase { ... } +public class StartupMonitorPhase : IStartupPhase { ... } +``` + +#### 阶段 3:拆分 UpdateEngineService + +将 1850 行的更新引擎拆分为独立的策略类: + +```csharp +// 更新引擎成为协调者,不再包含实现 +public class UpdateEngineService +{ + private readonly IUpdateApplier _legacyApplier; + private readonly IUpdateApplier _plondsApplier; + private readonly ISignatureVerifier _signatureVerifier; + private readonly IUpdateSnapshotManager _snapshotManager; + ... +} +``` + +#### 阶段 4:精简 App.axaml.cs + +将 850 行的 App 入口精简为纯粹的 Avalonia 应用初始化 + 委托给 `LauncherOrchestrator`: + +```csharp +public override void OnFrameworkInitializationCompleted() +{ + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var orchestrator = new LauncherOrchestrator(desktop, LauncherRuntimeContext.Current); + _ = orchestrator.RunAsync(); + } + base.OnFrameworkInitializationCompleted(); +} +``` + +#### 优点 +- **零部署风险**:不改变安装包结构、不引入新进程、不改变 IPC 拓扑 +- **增量重构**:可以一个职责域一个域地逐步重构,每次重构都可编译验证 +- **测试友好**:拆分后的各 Phase 和 Service 可以独立单元测试 +- **保持启动性能**:单进程内的函数调用无 IPC 开销 +- **为未来多进程做准备**:如果将来真的需要拆分进程,接口已经清晰 + +#### 缺点 +- 仍然是单进程:更新引擎崩溃会影响 Launcher 进程 +- 需要自律维持架构边界(没有编译级隔离) + +#### 改造工程量估算 +- 阶段 1(目录重组):~0.5 人天 +- 阶段 2(FlowCoordinator 拆分):~2-3 人天 +- 阶段 3(UpdateEngine 拆分):~1-2 人天 +- 阶段 4(App.axaml.cs 精简):~0.5-1 人天 +- **总计 ~4-7 人天**,且风险可控 + +--- + +## 3. 决策矩阵 + +| 维度 | Option A (多项目拆分) | Option B (内部解耦) | +|------|---------------------|-------------------| +| **改造风险** | 🔴 高:引入新 IPC、新故障模式 | 🟢 低:纯重构,行为不变 | +| **改造工期** | 🔴 3-5 人周 | 🟢 4-7 人天 | +| **启动性能** | 🔴 多进程启动开销 | 🟢 零额外开销 | +| **故障隔离** | 🟢 进程级隔离 | 🟡 需靠代码纪律 | +| **独立更新** | 🟢 各服务可独立版本 | 🔴 单一二进制 | +| **可测试性** | 🟢 天然隔离 | 🟢 接口拆分后等效 | +| **安装包大小** | 🔴 膨胀(多 EXE) | 🟢 不变 | +| **部署复杂度** | 🔴 谁更新 Launcher? | 🟢 VeloPack 现有流程 | +| **团队人力需求** | 🔴 需长期维护多套 IPC | 🟢 维护成本低 | + +--- + +## 4. 推荐方案 + +> [!IMPORTANT] +> **推荐 Option B:单项目内部解耦**,原因如下: + +1. **当前阶段的核心瓶颈不是项目边界,而是文件级别的职责混乱**。`LauncherFlowCoordinator` 一个文件 2034 行,`UpdateEngineService` 一个文件 1850 行,`App.axaml.cs` 一个文件 850 行——这才是真正影响可维护性的问题。 + +2. **Launcher 的核心约束决定了它必须是单一入口**。根据 [ARCHITECTURE.md](file:///d:/github/LanMountainDesktop/docs/ARCHITECTURE.md) 的定义,Launcher 是 "应用的唯一入口",负责版本选择、原子化更新和安全启动。这个约束使得多进程拆分的价值大打折扣。 + +3. **已经存在良好的外部进程隔离**。Host 主程序 (`LanMountainDesktop.exe`)、Air APP Host (`LanMountainDesktop.AirAppHost`) 都是独立进程。Launcher 只需要作为协调者存在,它不需要自己也拆成多个进程。 + +4. **改造投入产出比**。Option A 需要 3-5 人周且引入新风险,Option B 需要 4-7 人天且零风险,效果(可维护性、可测试性)几乎等效。 + +--- + +## 5. Open Questions + +1. **是否考虑将 Launcher 的 CLI 模式(`update check`、`update apply`、`plugin install`)独立成一个无 UI 的命令行工具?** 这是一个比全面拆分轻量得多的拆分点,可以让 CI/CD 和脚本调用不依赖 Avalonia 运行时。 + +2. **`UpdateEngineService` 是否打算支持 Launcher 自身的自更新?** 如果是,可能需要一个极简的 "bootstrap updater" 组件,这是唯一可能需要独立进程的场景。 + +3. **内部解耦后,是否要引入 `Microsoft.Extensions.DependencyInjection`?** 当前所有服务都是手动 `new` 的,引入 DI 容器可以自然地约束依赖方向,但也会增加 Launcher 启动路径的复杂度。 +