changed.对启动器重构的尝试

This commit is contained in:
lincube
2026-05-28 15:14:37 +08:00
parent 1ef47c780b
commit 313d093257
51 changed files with 4509 additions and 2478 deletions

View File

@@ -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 AStartup 诊断 + 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 B2RunAsync→LaunchPipeline+ILaunchPhase引入 LauncherOrchestrator删除 LauncherFlowCoordinator提交
status: completed
- id: phase-b-app-slim
content: Phase B3App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator提交
status: completed
- id: phase-c-di
content: Phase CLauncherServiceRegistration + 轻量 MS DI统一 CLI/GUI 装配,提交
status: completed
- id: phase-d-update-split
content: Phase DUpdateEngineService→门面+策略类Verifier/Activator/Rollback 等),提交
status: completed
- id: phase-e-guardrails
content: Phase ELauncherArchitectureTests + 文档 + 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; }
/// <returns>null = 继续下一阶段;非 null = 管道终止并返回结果</returns>
Task<LauncherResult?> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken);
}
internal sealed class LaunchPipeline
{
public LaunchPipeline(IEnumerable<ILaunchPhase> phases) { ... }
public Task<LauncherResult> 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 AStartup 子系统 + 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 B2Pipeline + Phase + LauncherOrchestrator
- 实现 `ILaunchPhase``LaunchPipeline``LauncherOrchestrator`
- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试)
- 删除 `LauncherFlowCoordinator*`
- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline`
### Phase B3App.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 DUpdateEngine 策略拆分(可与 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 本地 smokelaunch / 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. 明确不做
- 不新建 csprojLauncher.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

View File

@@ -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<LauncherResult> ExecuteCoreAsync(
CommandContext context,
UpdateEngineFacade updateEngine,
IUpdateEngine updateEngine,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
@@ -84,7 +84,7 @@ internal static class Commands
}
}
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineFacade updateEngine)
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, IUpdateEngine updateEngine)
{
return context.SubCommand.ToLowerInvariant() switch
{
@@ -102,7 +102,7 @@ internal static class Commands
};
}
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineFacade updateEngine)
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, IUpdateEngine updateEngine)
{
return await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),

View File

@@ -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<string, string>(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);
}
}

View File

@@ -1,7 +1,7 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Infrastructure;
namespace LanMountainDesktop.Launcher.Shell;
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
{

View File

@@ -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

View File

@@ -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

View File

@@ -1,6 +1,6 @@
using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Launcher.Infrastructure;
namespace LanMountainDesktop.Launcher.Shell;
/// <summary>
/// 启动器背景图片服务

View File

@@ -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;
/// <summary>
/// Launcher GUI 入口装配:创建编排器并驱动启动流程。
/// Launcher GUI composition root. It only wires services and dispatches to entry coordinators.
/// </summary>
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") ?? "<none>"}'.");
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<string, string>(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<LauncherResult> 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<string, string>(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<LauncherCoordinatorResponse> 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<string, string> BuildCoordinatorResultDetails(
LauncherCoordinatorStatus? status,
PublicShellActivationResult? activation)
{
return new Dictionary<string, string>(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<ErrorWindowResult> 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<bool> TryActivateExistingInstanceAsync()
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
return activation?.Accepted == true;
}
private static async Task<PublicShellActivationResult?> 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<IPublicShellControlService>();
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);
}

View File

@@ -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") ?? "<none>"}'.");
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<LauncherResult> 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<string, string>(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<LauncherCoordinatorResponse> 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<string, string> BuildCoordinatorResultDetails(
LauncherCoordinatorStatus? status,
PublicShellActivationResult? activation)
{
return new Dictionary<string, string>(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<ErrorWindowResult> 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<bool> TryActivateExistingInstanceAsync()
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
return activation?.Accepted == true;
}
private static async Task<PublicShellActivationResult?> 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<IPublicShellControlService>();
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;
}
}
}

View File

@@ -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<IUpdateEngine>(sp => new UpdateEngineFacade(sp.GetRequiredService<DeploymentLocator>()));
services.AddSingleton(sp => UpdateEngineFactory.Create(sp.GetRequiredService<DeploymentLocator>()));
services.AddSingleton<HostLaunchService>();
services.AddSingleton<StartupAttemptRegistry>();
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();

View File

@@ -2,7 +2,7 @@ using Avalonia;
using Avalonia.Styling;
using FluentAvalonia.Styling;
namespace LanMountainDesktop.Launcher.Infrastructure;
namespace LanMountainDesktop.Launcher.Shell;
/// <summary>
/// 主题服务,管理启动器的主题设置

View File

@@ -1,3 +1,4 @@
using LanMountainDesktop.Launcher.Shell;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Shell;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;

View File

@@ -1,3 +1,4 @@
using LanMountainDesktop.Launcher.Shell;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Startup;

View File

@@ -1,4 +1,5 @@
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Shell;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Startup;

View File

@@ -1,3 +1,5 @@
using LanMountainDesktop.Launcher.Shell;
namespace LanMountainDesktop.Launcher.Startup;
internal sealed class MonitorStartupPhase : ILaunchPhase

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
{
}
}
}

View File

@@ -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
{
}
}
}

View File

@@ -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<LauncherResult> 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);
}
}

View File

@@ -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."
};
}
}

View File

@@ -0,0 +1,416 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Update;
internal static class PlondsManifestParser
{
public static List<PlondsFileEntry> CollectFileEntries(PlondsFileMap fileMap)
{
var files = new List<PlondsFileEntry>();
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<PlondsFileEntry> 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<PlondsFileEntry> 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<PlondsFileEntry> 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<string, string> BuildMetadata(JsonElement node, string? componentName)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(componentName))
{
metadata["component"] = componentName;
}
PopulateMetadata(node, metadata);
return metadata;
}
private static void PopulateMetadata(JsonElement node, Dictionary<string, string> 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<string, string>? 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;
}
}

View File

@@ -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<string>();
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<string> 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));
}
}
}

View File

@@ -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<LauncherResult> 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<PlondsFileEntry> 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<PlondsFileEntry> 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
{
}
}
}

View File

@@ -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
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}

View File

@@ -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");
}

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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.");
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Type> 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);
}

View File

@@ -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
{
}
}
}

View File

@@ -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);

View File

@@ -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 处理) |

View File

@@ -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/`

View File

@@ -142,7 +142,7 @@ Task<UpdateCheckResult> 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<int> 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<LauncherResult> RunAsync()
internal sealed class LaunchPipeline
{
// 1. 清理旧版本
_deploymentLocator.CleanupDestroyedDeployments();
// 2. OOBE
if (_oobeStateService.IsFirstRun())
public async Task<LauncherResult> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default)
{
foreach (var step in _oobeSteps)
await step.RunAsync(CancellationToken.None);
}
foreach (var phase in _phases)
{
var result = await phase.ExecuteAsync(context, cancellationToken);
if (result.Status == LaunchPhaseStatus.Completed)
{
return result.Result!;
}
}
// 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.");
// 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),

View File

@@ -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 AStartup 诊断 + 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 B2RunAsync→LaunchPipeline+ILaunchPhase引入 LauncherOrchestrator删除 LauncherFlowCoordinator提交
status: completed
- id: phase-b-app-slim
content: Phase B3App.axaml.cs 精简为纯 Avalonia 初始化 + 委托 LauncherOrchestrator提交
status: completed
- id: phase-c-di
content: Phase CLauncherServiceRegistration + 轻量 MS DI统一 CLI/GUI 装配,提交
status: completed
- id: phase-d-update-split
content: Phase DUpdateEngineFacade→门面+策略类Verifier/Activator/Rollback 等),提交
status: completed
- id: phase-e-guardrails
content: Phase ELauncherArchitectureTests + 文档 + 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; }
/// <returns>null = 继续下一阶段;非 null = 管道终止并返回结果</returns>
Task<LauncherResult?> ExecuteAsync(LaunchContext context, CancellationToken cancellationToken);
}
internal sealed class LaunchPipeline
{
public LaunchPipeline(IEnumerable<ILaunchPhase> phases) { ... }
public Task<LauncherResult> 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 AStartup 子系统 + 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 B2Pipeline + Phase + LauncherOrchestrator
- 实现 `ILaunchPhase``LaunchPipeline``LauncherOrchestrator`
- 逐 Phase 从 Coordinator 迁移逻辑(可先并行运行对照测试)
- 删除 `LauncherFlowCoordinator*`
- `**git commit**`: `refactor(launcher): replace LauncherFlowCoordinator with LaunchPipeline`
### Phase B3App.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 DUpdateEngine 策略拆分(可与 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. 明确不做
- 不新建 csprojLauncher.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

View File

@@ -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.

View File

@@ -0,0 +1,88 @@
# Git 提交分析报告
## 基本信息
- **哈希**: 1ee6e68f33f0a1bbeccf7cef8f3767e65d6916c9
- **短哈希**: 1ee6e68
- **作者**: lincube &lt;lincube3@hotmail.com&gt;
- **时间**: 2026-05-28 10:28:31 +0800
- **合入作者**: Cursor &lt;cursoragent@cursor.com&gt;
## 提交信息摘要
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. 检查错误处理和日志记录是否完善

View File

@@ -0,0 +1,83 @@
# Git 提交分析报告
## 基本信息
- **哈希**: 1ef47c780bea380088d2615e8f4ec7d478ca5aa5
- **短哈希**: 1ef47c7
- **作者**: lincube &lt;lincube3@hotmail.com&gt;
- **时间**: 2026-05-28 11:13:14 +0800
- **合入作者**: Cursor &lt;cursoragent@cursor.com&gt;
## 提交信息摘要
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 处理)

View File

@@ -0,0 +1,45 @@
# Git 提交分析报告
## 基本信息
- **哈希**: 545dee85a79942a18b18a81a86a53bb700161f9d
- **短哈希**: 545dee8
- **作者**: lincube &lt;lincube3@hotmail.com&gt;
- **时间**: 2026-05-28 10:28:16 +0800
- **合入作者**: Cursor &lt;cursoragent@cursor.com&gt;
## 提交信息摘要
fix(launcher): wire HostStartupMonitor into launch flow
## 变更统计
| 指标 | 数值 |
|------|------|
| 变更文件数 | 1 |
| 新增行数 | 1 |
| 删除行数 | 1 |
| 净变化 | 0 |
## 详细变更分析
### 变更的文件
`LanMountainDesktop.Launcher/Startup/HostStartupMonitor.cs`
### 具体变更
修改了 `HostStartupMonitor` 中的 `Request` 记录的 `ComposeLaunchDetails` 函数签名:
- **之前**`Func<bool, bool, bool, Dictionary<string, string>>`
- **之后**`Func<bool, bool, Dictionary<string, string>>`
移除了第三个布尔参数。
## 代码审查要点
### 优势
1. **简化接口**:减少了不必要的参数
2. **保持兼容性**:这是一个小的调整,不会造成大的影响
### 潜在风险
1. **调用点需要同步更新**:需要确保所有调用 `HostStartupMonitor` 的地方都已同步更新
2. **参数用途不明确**:不清楚移除的参数原本的用途
### 建议
1. 检查所有调用点,确保已同步更新
2. 运行相关测试,确保功能正常

View File

@@ -0,0 +1,98 @@
# Git 提交分析报告
## 基本信息
- **哈希**: a26b6faace509f2ff8806e95fe5891ce4b325fc4
- **短哈希**: a26b6fa
- **作者**: lincube &lt;lincube3@hotmail.com&gt;
- **时间**: 2026-05-28 11:03:49 +0800
- **合入作者**: Cursor &lt;cursoragent@cursor.com&gt;
## 提交信息摘要
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. 进行充分的端到端测试,确保各种启动场景正常工作

View File

@@ -0,0 +1,109 @@
# Git 提交分析报告
## 基本信息
- **哈希**: b219f109ec1c69c21d57be7ac3e03dcd6f981877
- **短哈希**: b219f10
- **作者**: lincube &lt;lincube3@hotmail.com&gt;
- **时间**: 2026-05-28 10:43:30 +0800
- **合入作者**: Cursor &lt;cursoragent@cursor.com&gt;
## 提交信息摘要
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 说明

View File

@@ -0,0 +1,103 @@
# Git 提交分析报告
## 基本信息
- **哈希**: ebe35d6f91b844dcdf3729d0d894875804749e9a
- **短哈希**: ebe35d6
- **作者**: lincube &lt;lincube3@hotmail.com&gt;
- **时间**: 2026-05-28 10:27:33 +0800
- **合入作者**: Cursor &lt;cursoragent@cursor.com&gt;
## 提交信息摘要
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. 测试超时场景

View File

@@ -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<br/>(入口编排)"] --> B["LauncherFlowCoordinator<br/>(流程协调)"]
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<IStartupPhase> _phases;
public async Task<LauncherResult> 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 人天
- 阶段 2FlowCoordinator 拆分):~2-3 人天
- 阶段 3UpdateEngine 拆分):~1-2 人天
- 阶段 4App.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 启动路径的复杂度。