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 启动路径的复杂度。
+