Compare commits

...

2 Commits

Author SHA1 Message Date
lincube
a1cc0ee2bf changed.PLONDS启动 2026-05-28 20:07:03 +08:00
lincube
313d093257 changed.对启动器重构的尝试 2026-05-28 15:14:37 +08:00
69 changed files with 4583 additions and 2646 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

@@ -1,4 +1,4 @@
name: PLONDS
name: PLONDS Comparator
concurrency:
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
@@ -87,13 +87,11 @@ jobs:
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1

View File

@@ -1,4 +1,4 @@
name: DDSS Rollback
name: PLONDS Rollback
on:
workflow_dispatch:
@@ -26,7 +26,7 @@ jobs:
contents: read
concurrency:
group: ddss-rollback-${{ github.event.inputs.channel }}
group: plonds-rollback-${{ github.event.inputs.channel }}
cancel-in-progress: false
steps:
@@ -63,7 +63,7 @@ jobs:
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV"
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
- name: Validate rollback target assets
env:
@@ -77,7 +77,7 @@ jobs:
run: |
set -euo pipefail
for name in ddss.json ddss.json.sig; do
for name in plonds.json plonds.json.sig; do
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
@@ -90,10 +90,10 @@ jobs:
set -euo pipefail
mkdir -p rollback-output
pointer_file="rollback-output/ddss-latest.json"
pointer_file="rollback-output/plonds-latest.json"
manifest_url="${S3_BASE_URL}/ddss.json"
sig_url="${S3_BASE_URL}/ddss.json.sig"
manifest_url="${S3_BASE_URL}/plonds.json"
sig_url="${S3_BASE_URL}/plonds.json.sig"
version="${RELEASE_TAG#v}"
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -125,22 +125,22 @@ jobs:
run: |
set -euo pipefail
pointer_file="rollback-output/ddss-latest.json"
pointer_file="rollback-output/plonds-latest.json"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$DDSS_CHANNEL_POINTER_KEY" \
--key "$PLONDS_CHANNEL_POINTER_KEY" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
- name: Print rollback summary
shell: bash
run: |
set -euo pipefail
echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'."
echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json"
echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json"

View File

@@ -1,13 +1,13 @@
name: DDSS
name: PLONDS Publisher
concurrency:
group: ddss-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
group: plonds-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
cancel-in-progress: false
on:
workflow_run:
workflows:
- PLONDS
- PLONDS Comparator
types:
- completed
workflow_dispatch:
@@ -61,7 +61,7 @@ jobs:
CHANNEL="stable"
fi
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV"
echo "PLONDS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/plonds-latest.json" >> "$GITHUB_ENV"
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
if [[ -z "$PUBLIC_BASE" ]]; then
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
@@ -80,13 +80,11 @@ jobs:
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
@@ -141,7 +139,7 @@ jobs:
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
if [[ "$name" == "plonds.json" || "$name" == "plonds.json.sig" ]]; then
continue
fi
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
@@ -211,21 +209,21 @@ jobs:
--metadata "sha256=$sha256"
done
- name: Build DDSS manifest
- name: Build PLONDS manifest
shell: bash
run: |
set -euo pipefail
mkdir -p ddss-output
mkdir -p plonds-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-ddss \
build-plonds \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir ddss-output \
--output-dir plonds-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
- name: Validate DDSS asset references in Rainyun S3
- name: Validate PLONDS asset references in Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
@@ -236,12 +234,12 @@ jobs:
shell: bash
run: |
set -euo pipefail
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' ddss-output/ddss.json \
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' plonds-output/plonds.json \
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
| sort -u)
if [[ -z "$keys" ]]; then
echo "No S3-backed asset URLs found in ddss.json"
echo "No S3-backed asset URLs found in plonds.json"
exit 1
fi
@@ -252,15 +250,15 @@ jobs:
--key "$key" >/dev/null
done <<< "$keys"
- name: Upload DDSS manifest to release
- name: Upload PLONDS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
gh release upload "$RELEASE_TAG" plonds-output/plonds.json plonds-output/plonds.json.sig --clobber
- name: Upload DDSS manifest to Rainyun S3 staging
- name: Upload PLONDS manifest to Rainyun S3 staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
@@ -271,7 +269,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
for file in plonds-output/plonds.json plonds-output/plonds.json.sig; do
name="$(basename "$file")"
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
@@ -282,11 +280,11 @@ jobs:
--metadata "sha256=$sha256"
done
- name: Prepare DDSS channel pointer
- name: Prepare PLONDS channel pointer
shell: bash
run: |
set -euo pipefail
pointer_file="ddss-output/ddss-latest.json"
pointer_file="plonds-output/plonds-latest.json"
cat > "$pointer_file" <<'JSON'
{
"schemaVersion": 1,
@@ -301,8 +299,8 @@ jobs:
}
JSON
manifest_url="${S3_BASE_URL}/ddss.json"
sig_url="${S3_BASE_URL}/ddss.json.sig"
manifest_url="${S3_BASE_URL}/plonds.json"
sig_url="${S3_BASE_URL}/plonds.json.sig"
version="${RELEASE_TAG#v}"
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -315,7 +313,7 @@ jobs:
jq -e . "$pointer_file" >/dev/null
- name: Atomically publish DDSS channel pointer
- name: Atomically publish PLONDS channel pointer
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
@@ -326,8 +324,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
pointer_file="ddss-output/ddss-latest.json"
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/ddss-latest.json"
pointer_file="plonds-output/plonds-latest.json"
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/plonds-latest.json"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
@@ -336,14 +334,14 @@ jobs:
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$DDSS_CHANNEL_POINTER_KEY" \
--key "$PLONDS_CHANNEL_POINTER_KEY" \
--body "$pointer_file"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
--bucket "$S3_BUCKET" \
--key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null
--key "$PLONDS_CHANNEL_POINTER_KEY" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/plonds-latest.json" >/dev/null
- name: Verify Rainyun S3 PLONDS output
env:

View File

@@ -1,16 +0,0 @@
# Checklist
- [x] `release.yml` does not invoke Velopack.
- [x] `plonds-build.yml` uploads app payload artifacts and generates PloNDS delta/static outputs.
- [x] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
- [x] CI workflow expects `repo/`, `meta/`, `manifests/`, and `installers/` outputs after a release run.
- [x] Host update source keeps compatibility (`pdc`/`stcn` normalize to active PloNDS source).
- [x] Host can persist PloNDS payload into launcher incoming directory.
- [x] Launcher can apply PloNDS FileMap payload with signature/hash verification.
- [x] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
- [x] Launcher keeps rollback-capable deployments after successful update.
- [x] Manual rollback returns a structured failure when the snapshot source directory is missing.
- [ ] CI run attached proving all release matrix jobs pass.
- [x] N-1 -> N incremental update verified locally on Windows x64.
- [ ] N-1 -> N incremental update verified on Windows x86 and Linux x64.
- [x] Rollback regression tests attached in `LanMountainDesktop.Tests`.

View File

@@ -1,48 +0,0 @@
# PDC Incremental Update Migration
## Goal
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
## Stage 1 (Completed)
- Release workflow removed VeloPack-based release packaging.
- Signed FileMap path was restored as an interim release mechanism.
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
## Stage 2 (Current Implementation Target)
- Use GitHub Actions PloNDS static publishing as the active incremental path.
- Keep `phainon.yml` for future PDCC parity, but do not rely on PDCC for the current release flow.
- Promote PloNDS-distributed FileMap/object-repo as the primary incremental path.
- Keep GitHub Release installers and metadata as parallel distribution.
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
- Check updates in order: NS3/PloNDS static source, GitHub Release PloNDS assets, then GitHub full installer.
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
- Public object URLs come from `S3_PUBLIC_BASE_URL`; do not infer them from `S3_ENDPOINT` and `S3_BUCKET`.
Expected S3 layout:
- `lanmountain/update/repo/sha256/<hash-prefix>/<hash-object>`
- `lanmountain/update/meta/channels/<channel>/<platform>/latest.json`
- `lanmountain/update/meta/distributions/<distributionId>.json`
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json`
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json.sig`
- `lanmountain/update/installers/<platform>/<version>/*`
## Acceptance
- `release.yml` contains no Velopack steps; PloNDS static publishing is handled by `plonds-build.yml` and `ddss-publish.yml`.
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
- PloNDS metadata + FileMap + object repo are published under `lanmountain/update/`.
- Host can consume the NS3/PloNDS static payload and fallback to GitHub when unavailable.
- Launcher can apply both:
- legacy signed `files.json + update.zip`
- PloNDS FileMap object-repo payload.
- Rollback semantics keep both automatic failure rollback and manual rollback after a successful update.
## Deprecated Notes
- The following interim outputs are compatibility-only (not the long-term primary path):
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`

View File

@@ -1,21 +0,0 @@
# Tasks
- [x] Remove VeloPack packaging from release workflow.
- [x] Keep signed FileMap path as interim compatibility fallback.
- [x] Remove launcher/runtime Velopack branching.
- [x] Add `phainon.yml` for PDCC publish configuration.
- [ ] Add PDCC installation + publish steps in `release.yml` (deferred; active path is GitHub Actions PloNDS static publish).
- [x] Upload app payload artifacts for PloNDS delta generation in release build jobs.
- [x] Publish PloNDS metadata + sha256 object repo to S3 path root `lanmountain/update/`.
- [x] Mirror installers to `lanmountain/update/installers/<platform>/<version>/`.
- [x] Keep update source compatibility (`pdc`/`stcn` normalize to active PloNDS source).
- [x] Add PloNDS static payload model into host update check result.
- [x] Add host download path for PloNDS payload (`plonds-filemap.json` + signature + object repo).
- [x] Add launcher PloNDS FileMap apply path with rollback-compatible semantics.
- [x] Keep old `files.json + update.zip` path behind compatibility fallback.
- [x] Keep rollback deployment directories after successful apply and prune by bounded retention.
- [x] Return structured failure when manual rollback snapshot source is missing.
- [x] Verify static S3 layout, filemap/signature, distribution, latest pointer, and at least one object in CI workflows.
- [x] Add regression tests for PloNDS success rollback, hash-failure auto rollback, missing rollback source, static NS3 manifest, and manifest field mapping.
- [ ] Attach live CI run proving the full release matrix passes.
- [ ] Verify N-1 -> N incremental update on Windows x86 and Linux x64 in release artifacts.

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 plondsFileMapText = File.ReadAllText(paths.PlondsFileMapPath);
var plondsFileMap = JsonSerializer.Deserialize(plondsFileMapText, AppJsonContext.Default.PlondsFileMap);
if (plondsFileMap is null)
{
return UpdateEngineResults.Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid.");
}
var plondsVerified = signatureVerifier.Verify(
paths.PlondsFileMapPath,
paths.PlondsSignaturePath,
UpdateEnginePaths.PlondsSignatureFileName);
if (!plondsVerified.Success)
{
return UpdateEngineResults.Failed("update.check", "signature_failed", plondsVerified.Message);
}
var plondsMetadata = 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(plondsFileMap, plondsMetadata)
};
}
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 plondsMetadata = 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, plondsMetadata);
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, plondsMetadata);
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

@@ -15,7 +15,7 @@ public sealed record UpdateManifest(
IReadOnlyList<UpdateMirrorAsset>? InstallerMirrors,
IReadOnlyDictionary<string, string> Metadata)
{
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds;
public long EstimatedDeltaBytes
{

View File

@@ -22,7 +22,6 @@ public enum UpdatePhase
public enum UpdatePayloadKind
{
DeltaPlonds,
DeltaLegacy,
FullInstaller
}

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

@@ -37,7 +37,7 @@ internal sealed class UpdateInstallGateway
return new InstallResult(false, lockError, false, lockErrorCode);
}
if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy)
if (payloadKind is UpdatePayloadKind.DeltaPlonds)
{
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
if (!launched)
@@ -108,7 +108,7 @@ internal sealed class UpdateInstallGateway
return false;
}
var expectedKind = payloadKind is UpdatePayloadKind.DeltaLegacy or UpdatePayloadKind.DeltaPlonds ? "delta" : "full";
var expectedKind = payloadKind is UpdatePayloadKind.DeltaPlonds ? "delta" : "full";
if (!string.Equals(deploymentLock.Kind, expectedKind, StringComparison.OrdinalIgnoreCase))
{
errorCode = "lock_conflict";

View File

@@ -15,11 +15,7 @@ public static class UpdateSettingsValues
// NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePlonds = "plonds-api";
public const string DownloadSourcePdc = DownloadSourcePlonds;
public const string DownloadSourceStcn = DownloadSourcePlonds;
public const string LegacyDownloadSourcePlonds = "pdc";
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
public const string LegacyDownloadSourceStcn = "stcn";
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
public const string PlondsStaticBaseUrlEnvironmentVariable = "LANMOUNTAIN_UPDATE_BASE_URL";
@@ -62,12 +58,12 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, "pdc", StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePlonds;
}
if (string.Equals(value, LegacyDownloadSourceStcn, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, "stcn", StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePlonds;
}

View File

@@ -99,7 +99,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
[ObservableProperty] private bool _forceReinstall;
[ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
[ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
[ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePlonds;
[ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeSilentDownload;
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
@@ -220,7 +220,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
LatestVersionText = report.LatestVersion ?? string.Empty;
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty;
UpdateTypeText = GetUpdateTypeText(report.PayloadKind);
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds;
StatusMessage = report.LatestVersion is null
? GetUpdateAvailableStatusText(string.Empty)
: string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), report.LatestVersion);
@@ -627,7 +627,7 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
return payloadKind switch
{
UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy => L("settings.update.type_delta", "Incremental Update"),
UpdatePayloadKind.DeltaPlonds => L("settings.update.type_delta", "Incremental Update"),
UpdatePayloadKind.FullInstaller => L("settings.update.type_reinstall", "Reinstall"),
_ => string.Empty
};

View File

@@ -1,6 +1,6 @@
namespace Plonds.Core.Publishing;
public sealed record DdssBuildOptions(
public sealed record PlondsBuildOptions(
string ReleaseTag,
string AssetsDirectory,
string OutputRoot,

View File

@@ -3,18 +3,18 @@ using Plonds.Shared.Models;
namespace Plonds.Core.Publishing;
public sealed class DdssManifestBuilder
public sealed class PlondsManifestBuilder
{
private readonly RsaFileSigner _signer = new();
public string Build(DdssBuildOptions options)
public string Build(PlondsBuildOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var assetsDirectory = Path.GetFullPath(options.AssetsDirectory);
if (!Directory.Exists(assetsDirectory))
{
throw new DirectoryNotFoundException($"DDSS assets directory not found: {assetsDirectory}");
throw new DirectoryNotFoundException($"PLONDS assets directory not found: {assetsDirectory}");
}
var assetEntries = Directory
@@ -22,14 +22,14 @@ public sealed class DdssManifestBuilder
.Where(static path =>
{
var name = Path.GetFileName(path);
return !name.Equals("ddss.json", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("ddss.json.sig", StringComparison.OrdinalIgnoreCase);
return !name.Equals("plonds.json", StringComparison.OrdinalIgnoreCase)
&& !name.Equals("plonds.json.sig", StringComparison.OrdinalIgnoreCase);
})
.OrderBy(static path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)
.Select(path => BuildAssetEntry(path, options.Repository, options.ReleaseTag, options.S3BaseUrl))
.ToArray();
var manifest = new DdssManifest(
var manifest = new PlondsManifest(
FormatVersion: "1.0",
ReleaseTag: options.ReleaseTag,
GeneratedAt: DateTimeOffset.UtcNow,
@@ -37,28 +37,28 @@ public sealed class DdssManifestBuilder
var outputRoot = Path.GetFullPath(options.OutputRoot);
Directory.CreateDirectory(outputRoot);
var manifestPath = Path.Combine(outputRoot, "ddss.json");
var manifestPath = Path.Combine(outputRoot, "plonds.json");
PayloadUtilities.WriteJson(manifestPath, manifest);
_signer.SignFile(manifestPath, options.PrivateKeyPath, manifestPath + ".sig");
return manifestPath;
}
private static DdssAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl)
private static PlondsAssetEntry BuildAssetEntry(string assetPath, string repository, string releaseTag, string? s3BaseUrl)
{
var fileName = Path.GetFileName(assetPath);
var mirrors = new List<DdssMirrorEntry>
var mirrors = new List<PlondsMirrorEntry>
{
new("github", $"https://github.com/{repository}/releases/download/{releaseTag}/{Uri.EscapeDataString(fileName)}")
};
if (!string.IsNullOrWhiteSpace(s3BaseUrl))
{
mirrors.Add(new DdssMirrorEntry(
mirrors.Add(new PlondsMirrorEntry(
"s3",
$"{s3BaseUrl.TrimEnd('/')}/{Uri.EscapeDataString(fileName)}"));
}
return new DdssAssetEntry(
return new PlondsAssetEntry(
AssetId: fileName,
FileName: fileName,
Sha256: PayloadUtilities.ComputeSha256(assetPath),

View File

@@ -1,8 +1,8 @@
namespace Plonds.Shared.Models;
public sealed record DdssAssetEntry(
public sealed record PlondsAssetEntry(
string AssetId,
string FileName,
string Sha256,
long Size,
IReadOnlyList<DdssMirrorEntry> Mirrors);
IReadOnlyList<PlondsMirrorEntry> Mirrors);

View File

@@ -1,7 +1,7 @@
namespace Plonds.Shared.Models;
public sealed record DdssManifest(
public sealed record PlondsManifest(
string FormatVersion,
string ReleaseTag,
DateTimeOffset GeneratedAt,
IReadOnlyList<DdssAssetEntry> Assets);
IReadOnlyList<PlondsAssetEntry> Assets);

View File

@@ -1,5 +1,5 @@
namespace Plonds.Shared.Models;
public sealed record DdssMirrorEntry(
public sealed record PlondsMirrorEntry(
string Type,
string Url);

View File

@@ -38,8 +38,8 @@ internal static class PlondsCli
case "build-index":
RunBuildIndex(options);
return Task.FromResult(0);
case "build-ddss":
RunBuildDdss(options);
case "build-plonds":
RunBuildPlonds(options);
return Task.FromResult(0);
default:
Console.Error.WriteLine($"Unknown command: {command}");
@@ -157,10 +157,10 @@ internal static class PlondsCli
Console.WriteLine(manifestPath);
}
private static void RunBuildDdss(Dictionary<string, string> options)
private static void RunBuildPlonds(Dictionary<string, string> options)
{
var builder = new DdssManifestBuilder();
var manifestPath = builder.Build(new DdssBuildOptions(
var builder = new PlondsManifestBuilder();
var manifestPath = builder.Build(new PlondsBuildOptions(
ReleaseTag: Require(options, "release-tag"),
AssetsDirectory: Require(options, "assets-dir"),
OutputRoot: Require(options, "output-dir"),
@@ -215,7 +215,7 @@ internal static class PlondsCli
Console.WriteLine(" pack-payload --source-dir <dir> --output-zip <file>");
Console.WriteLine(" build-delta --platform <platform> --current-version <v> --current-tag <tag> --current-zip <file> --output-dir <dir> --private-key <pem> [--baseline-tag <tag>] [--baseline-version <v>] [--baseline-zip <file>] [--is-full-payload] [--static-output-dir <dir>] [--update-base-url <url>]");
Console.WriteLine(" build-index --release-tag <tag> --version <v> --platform-summaries-dir <dir> --output-dir <dir> --private-key <pem> [--channel <channel>]");
Console.WriteLine(" build-ddss --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
Console.WriteLine(" build-plonds --release-tag <tag> --assets-dir <dir> --output-dir <dir> --private-key <pem> --repository <owner/repo> [--s3-base-url <url>]");
Console.WriteLine(" sign --manifest <file> --private-key <pem> [--output <file>]");
Console.WriteLine(" generate --current-version <v> --current-dir <dir> --platform <platform> --output-dir <dir> [--previous-version <v>] [--previous-dir <dir>]");
Console.WriteLine(" publish --version <v> --app-artifacts-root <dir> --installer-artifacts-root <dir> --output-dir <dir> --private-key <pem> [--baseline-root <dir>]");

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);
}
// 3. Splash
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
{
var window = new SplashWindow();
window.Show();
return window;
});
try
{
// 4. 应用更新
var updateResult = await _updateEngine.ApplyPendingUpdateAsync();
if (!updateResult.Success)
Logger.Warn("Update apply failed, will try to launch existing version.");
foreach (var phase in _phases)
{
var result = await phase.ExecuteAsync(context, cancellationToken);
if (result.Status == LaunchPhaseStatus.Completed)
{
return result.Result!;
}
}
// 5. 启动主程序(插件 pending 由 Host 应用,不在 Launcher 启动步骤处理)
var hostResult = await LaunchHostWithIpcAsync();
if (!hostResult.Success)
return hostResult;
return new LauncherResult { Success = true };
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
return LaunchResultBuilder.BuildFailure("launch", "pipeline_incomplete", "Launch pipeline finished without producing a result.");
}
}
```
`LauncherFlowCoordinator` 已删除。GUI 顶层生命周期由 `LauncherGuiCoordinator` 处理,启动阶段由 `LaunchPipeline` 和各 `ILaunchPhase` 承载;更新 apply 通过 `IUpdateEngine` 门面进入 `Update/` 策略类。
## 命令行接口
### launch - 启动应用
@@ -505,7 +474,7 @@ public class MyOobeStep : IOobeStep
}
```
2.`LauncherFlowCoordinator` 中注册:
2.`Shell/LauncherOrchestrator.cs` 的 OOBE step 组装处注册,或通过后续 `ILaunchPhase`/OOBE 装配点接入:
```csharp
_oobeSteps = [
new WelcomeOobeStep(_oobeStateService),

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

@@ -31,7 +31,7 @@
| Category | Count | Files |
|----------|-------|-------|
| CI/CD Workflows | 3 | `ddss-publish.yml`, `ddss-rollback.yml`, `plonds-build.yml` |
| CI/CD Workflows | 3 | `plonds-uploader.yml`, `plonds-rollback.yml`, `plonds-comparator.yml` |
| Shared Contracts | 3 | `DeploymentLock.cs`, `UpdatePaths.cs`, `UpdateState.cs` |
| Launcher | 2 | `AppJsonContext.cs`, `UpdateModels.cs` |
| Services | 7 | `UpdateEngineService.cs`, `DeploymentLockService.cs`, `UpdateDownloadEngine.cs`, `UpdateInstallGateway.cs`, `UpdateOrchestrator.cs`, `UpdateWorkflowService.cs`, `WindowPassthroughService.cs` |
@@ -49,7 +49,7 @@
### 1. CI/CD Workflows Enhancement
#### `.github/workflows/ddss-publish.yml` (+106 lines)
#### `.github/workflows/plonds-uploader.yml` (+106 lines)
**Purpose:** Enhanced deployment publishing workflow
**Key Changes:**
@@ -58,7 +58,7 @@
- Added atomic channel pointer publishing
- Improved deployment pipeline reliability
#### `.github/workflows/ddss-rollback.yml` (+146 lines) — **NEW**
#### `.github/workflows/plonds-rollback.yml` (+146 lines) — **NEW**
**Purpose:** Automated rollback workflow
**Key Features:**
@@ -66,7 +66,7 @@
- Emergency deployment recovery
- Version rollback automation
#### `.github/workflows/plonds-build.yml` (+5 lines)
#### `.github/workflows/plonds-comparator.yml` (+5 lines)
**Changes:**
- Adjusted build concurrency settings
- Updated release event triggers

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